Commit graph

6 commits

Author SHA1 Message Date
Abhay Singh
e4904e23a6
fix(backends/anyllm): stream tool_use blocks and map finish_reason on the streaming path
Preserve AnyLLM streaming tool calls and finish reasons.
2026-08-11 18:15:57 -07:00
Abhay Singh
0d6866b91a
fix(backends/anyllm): convert Anthropic tools and tool_choice to OpenAI shape
Convert Anthropic tool requests for AnyLLM OpenAI-compatible backends.
2026-08-11 18:15:47 -07:00
Abhay Singh
43a7b578a1
fix(backends): don't crash the OpenAI->Anthropic converter on empty choices (#2484)
## Description

`_to_anthropic_response` in both backends converts a non-streaming
OpenAI-shape response to Anthropic shape and indexes the first choice
directly:

```python
# headroom/backends/litellm.py
choice = litellm_response.choices[0]
# headroom/backends/anyllm.py
choice = response.choices[0]
```

A non-streaming upstream response can be HTTP 200 with an **empty**
`choices` list: Azure OpenAI content filtering does exactly this, and
any OpenAI-compatible gateway can return a usage-only / filtered turn
the same way. With `choices: []`, `choices[0]` raises `IndexError`,
which surfaces as a 500 for the request instead of a normal (if empty)
turn.

This is an intra-file asymmetry: the streaming siblings in the same two
files already guard it (`if not chunk.choices: continue` / `if
hasattr(chunk, "choices") and chunk.choices:`), and
`headroom/proxy/handlers/openai.py` documents the exact hazard in
`_apply_stream_usage_option`: "the common `chunk.choices[0].delta`
pattern then raises IndexError" on a usage-only `choices: []` chunk. The
non-streaming converters just never got the same guard.

## Fix

Return a valid empty assistant turn (`content: []`, `stop_reason:
"end_turn"`, usage still mapped) when `choices` is empty, before
indexing. The client gets a clean empty response instead of a 500,
matching how the streaming path already tolerates the same shape.
Non-empty responses are unchanged.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/backends/litellm.py`: empty-`choices` guard at the top of
`_to_anthropic_response`, returning an empty assistant turn with mapped
usage.
- `headroom/backends/anyllm.py`: same guard in its
`_to_anthropic_response`.
- `tests/test_litellm_nonstream_cache_usage.py`,
`tests/test_backend_anyllm.py`: regressions passing an empty-`choices`
response through each converter and asserting an empty turn instead of
IndexError.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
$ python -m pytest tests/test_litellm_nonstream_cache_usage.py::test_to_anthropic_response_empty_choices_returns_empty_turn tests/test_backend_anyllm.py::test_to_anthropic_response_empty_choices_returns_empty_turn -q
2 passed

# with the fix reverted, both fail with
# IndexError: list index out of range

$ uvx ruff@0.15.17 check headroom/backends/litellm.py headroom/backends/anyllm.py tests/test_backend_anyllm.py tests/test_litellm_nonstream_cache_usage.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/backends/litellm.py headroom/backends/anyllm.py
Success: no issues found in 2 source files
```

Note: `tests/test_backend_anyllm.py` has 7 `@pytest.mark.asyncio` tests
that fail locally because pytest-asyncio is not configured in this
environment (`Unknown config option: asyncio_mode`); they are unrelated
to this change and pass in CI. The two new tests here are synchronous
and pass locally.

## Real Behavior Proof

- Environment: Windows 11, Python 3.12, project venv (`uv sync --extra
proxy`), `uvx ruff@0.15.17` / `uvx mypy@1.20.2`, pytest in the venv.
- Exact command / steps: built a response stand-in with `choices=[]` and
a usage object, called `LiteLLMBackend._to_anthropic_response` (on a
bare `object.__new__` instance) and
`AnyLLMBackend._to_anthropic_response` (via the file's fake-backend
fixture); then reverted both backend files and re-ran.
- Observed result: with the fix each converter returns `{type: message,
role: assistant, content: [], stop_reason: end_turn, usage: {...}}` with
the input/output token counts mapped; with the fix reverted both raise
`IndexError: list index out of range`. Ran against the actual modules
via the two test files.
- Not tested: a live Azure OpenAI content-filtered response routed
through the backend end to end.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable
2026-07-22 06:08:10 -07:00
Aniruddh Jha
a7ee8a60a7
fix(anyllm): forward openai api_base/api_key to the any-llm backend (#942) (#954)
## Description

The any-llm backend ignored `--openai-api-url`, so requests against
custom OpenAI-compatible providers (vLLM, LiteLLM, xiaomimimo.com, etc.)
were sent to `api.openai.com` instead of the configured URL, returning
401s. This wires the configured URL all the way through to the any-llm
client.

Closes #942

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

There were two layers to the bug, both fixed here:

- The URL was never threaded to the backend. `create_proxy_backend()`
did not accept or forward the configured OpenAI URL, so `AnyLLMBackend`
was always constructed without an `api_base`. It now takes
`openai_api_url` and passes it through as `api_base`, wired from
`config.openai_api_url` in `server.py`.
- The backend never applied it. `AnyLLMBackend.__init__` stored
`self.api_base` and `self.api_key` but never used them;
`AnyLLM.create()` only received the provider. Both are now forwarded to
`AnyLLM.create()`, and only when set, so providers that rely on their
own env-var defaults (`OPENAI_API_KEY` / `OPENAI_BASE_URL`) are
unaffected.

Files touched:
- `headroom/providers/registry.py` — `create_proxy_backend()` gains an
`openai_api_url` parameter, passed to the any-llm backend as `api_base`.
- `headroom/proxy/server.py` — pass
`openai_api_url=config.openai_api_url` into `create_proxy_backend()`.
- `headroom/backends/anyllm.py` — forward `api_key`/`api_base` to
`AnyLLM.create()` when set.

Verified against `any-llm-sdk` 1.17.0, whose `AnyLLM.create(provider,
api_key=None, api_base=None, ...)` accepts both parameters.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
$ pytest tests/test_backend_anyllm.py tests/test_provider_registry_extended.py tests/test_provider_registry.py -q
tests/test_backend_anyllm.py ..............                              [ 43%]
tests/test_provider_registry_extended.py .......                         [ 65%]
tests/test_provider_registry.py ...........                              [100%]
32 passed

$ ruff check headroom/backends/anyllm.py headroom/providers/registry.py headroom/proxy/server.py tests/test_backend_anyllm.py tests/test_provider_registry_extended.py
All checks passed!
```

## Real Behavior Proof

- Environment: macOS (ARM64), Python 3.13, any-llm-sdk 1.17.0
- Exact command / steps: introspected `AnyLLM.create` signature from
any-llm-sdk 1.17.0 to confirm it accepts `api_base`, then ran the unit
suites above which assert the URL is threaded through
`create_proxy_backend` into `AnyLLM.create`.
- Observed result: with `openai_api_url` set, `AnyLLMBackend` is now
constructed with `api_base=<url>` and `AnyLLM.create()` receives it;
previously it received only the provider and the value was dropped.
- Not tested: live end-to-end request against a real custom
OpenAI-compatible endpoint (no credentials available in this
environment); mypy was not run locally.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable

## Additional Notes

Documentation and CHANGELOG updates are N/A: this restores intended
behavior of an existing documented flag (`--openai-api-url`) rather than
adding new surface. `mypy` and live end-to-end testing were not run in
this environment.
2026-06-15 11:07:43 -05:00
Garm
efd2ac1ca4 chore: renormalize line endings to LF
`.gitattributes` declares `*.py text eol=lf` and `*.sh text eol=lf`, but
74 files (73 .py, 1 .sh) are stored in the index with CRLF line endings,
violating that contract. Every macOS/Linux clone reports these files as
"modified" on fresh checkout because git's diff engine sees the stored
bytes don't match the attribute contract, even though the working tree
and index match byte-for-byte.

Running `git add --renormalize .` rewrites each affected blob so the
stored form matches the attribute declaration. No semantic changes —
every affected file's diff is "N insertions, N deletions" with inserts
and deletes being the same lines modulo line endings.

Follow-up commit adds `.git-blame-ignore-revs` so `git blame` / GitHub
blame skip this mechanical commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:33:30 +02:00
JerrettDavis
38bf3e639c test: expand coverage across helper slices
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 07:39:52 -05:00