Files
vectorize-io__hindsight/hindsight-api-slim/tests/test_meta_tool_choice.py
T
Nicolò Boschi 30ce3b8d11 feat(llm): add Meta Model API as a first-class provider (#4081)
* feat(llm): add Meta Model API as a first-class provider

Meta Model API (https://ai.developer.meta.com) serves the Muse family over an
OpenAI-compatible chat/completions endpoint, so it slots into the existing
OpenAICompatibleLLM path exactly like deepseek / zai / atlas.

Set `HINDSIGHT_API_LLM_PROVIDER=meta` to route fact extraction, reflection and
consolidation through it. The base URL defaults to https://api.meta.ai/v1 and
the default model is muse-spark-1.3 (1M context).

Two Meta-specific behaviours are worth knowing, and are documented rather than
worked around:

- Muse Spark always reasons. `reasoning_effort: "none"` is rejected with HTTP
  400 — only minimal/low/medium/high/xhigh are accepted, or omit it. Reasoning
  tokens are billed against the output budget, so the per-operation max-token
  limits need headroom.
- Chat Completions documents `max_tokens`, not `max_completion_tokens`. Muse
  Spark is a reasoning model but not one of the OpenAI products the frozen
  `_supports_reasoning_model` name list recognises, so the parameter name comes
  from the provider default. A test pins that, and pins that a configured
  reasoning_effort still reaches the request (the #3449 drop list only covers
  OpenAI's own non-reasoning products).

The provider is registered on the chat/completions path only. Meta also serves
a Responses and an Anthropic-Messages endpoint; a `meta-responses` variant
mirroring openai/openai-responses was considered and deliberately left out.

Capability flags are left off deliberately: Meta has no batch endpoint, and its
prompt caching is automatic (no key, flag, or breakpoints), so it does not fit
the explicit `get_or_create_cached_prefix` contract `supports_prompt_caching`
describes — the benefit applies for free either way.

Changes:
- engine/llm_wrapper.py: register "meta" in create_llm_provider(),
  LLMProvider.valid_providers, and the default base_url map
- engine/providers/openai_compatible_llm.py: register "meta" in
  valid_providers, default base_url, and the API-key-required check
- config.py: PROVIDER_DEFAULT_MODELS["meta"] = muse-spark-1.3
- tests/test_meta_provider.py: default model/base URL, API-key requirement,
  the max_tokens parameter name, and reasoning_effort pass-through
- hindsight-embed control center: add Meta Model API to the provider wizard
- docs: add Meta to llmProviders.json (drives the providers grid, the
  capability table and the default-models table) and config examples in
  developer/models.mdx + developer/configuration.md
- README + .env.example (+ the bundled embed copy): document the new provider

Also colours the docs provider grid, which was previously monochrome. Each tile
now carries its brand colour, taken from the Simple Icons dataset — the same
project the marks themselves come from, so a tile's colour matches its mark.
Near-black brands (OpenAI, Ollama, Anthropic, ...) get a dark-theme override so
they do not vanish against the dark surface. Providers with no Simple Icons
entry (Groq, Fireworks, Atlas Cloud, Requesty, opencode-go, Nous, llama.cpp,
LiteLLM) keep the neutral inherited colour rather than an invented hex. The
fallback is `inherit`, so the other IconGrid caller (ClientsGrid) is unchanged.

Meta uses its own mark (SiMeta) rather than the generic OpenAI-compatible glyph.

Not verified against the live API — no Meta API key was available — so
muse-spark-1.3 is deliberately absent from the "Tested Models" table, which
means models verified to work. Worth probing first with a real key: Meta
rejects recursive JSON schemas in structured output with HTTP 400.

* docs(llm): mark muse-spark-1.3 tested against the live Meta API

Verified end-to-end through Hindsight's own create_llm_provider() against
https://api.meta.ai/v1 (HTTP 200, valid content, token usage parsed including
reasoning tokens), so muse-spark-1.3 now belongs in the Tested Models table.

Four behaviours confirmed live, all matching the published docs:

- Structured output with a flat json_schema works, and classified the probe
  input correctly (world vs experience).
- Recursive JSON schemas are rejected: HTTP 400 "Recursive JSON schemas are not
  currently supported". Audited every Pydantic response model in
  engine/response_models.py for self-reference through $defs — none is
  recursive, so no Hindsight path is affected.
- reasoning_effort "none" is rejected: HTTP 400 '"reasoning_effort" does not
  support "none" with this model.' Other levels are accepted.
- Reasoning tokens are substantial and come out of the output budget: a trivial
  prompt spent 87 reasoning tokens against 11 visible output tokens, and at
  max_tokens=64 the response comes back with no content at all. Hindsight's
  defaults leave ample room (retain 64000; consolidation and reflect unbounded),
  so this only bites an operator who lowers the cap — which is what the
  configuration note added with the provider already warns about.

Also records in the max-tokens test that Meta accepts max_completion_tokens as
well, so sending max_tokens is a choice between two working names rather than a
correctness fix.

* fix(llm): reflect failed outright on Meta — tool_choice is auto-only

Found by running a real Hindsight instance against Meta Model API and
exercising retain, recall, reflect and consolidation end to end. Reflect
returned HTTP 400 on every call:

    only `"auto"` is supported for `tool_choice`. `"none"`, `"required"`,
    and named function choices are not currently supported

Reflect's agent loop forces a retrieval tool on its first turns, so the whole
reflect surface was unusable on this provider. The unit tests could not have
caught it: they cover provider construction and parameter naming, not the
tool-calling path.

This is the opposite failure mode to the one `_drops_tool_choice_required`
handles. LM Studio and Ollama accept the field and silently ignore it, so
reflect answers badly (#1563/#1179); Meta rejects the request outright, so
reflect answers not at all. The two need separate predicates, hence
`_rejects_non_auto_tool_choice` alongside the existing check rather than a
widening of it.

The field is dropped for any non-auto mode. A named choice has already been
narrowed to a single tool by the block above, so the call stays practically
forced under auto — the same reasoning the DeepSeek branch relies on. "none"
cannot be expressed by omission and would become "auto"; no caller reaches
this path with it (only the gemini, claude-code and github-copilot providers
handle NONE), so that is documented in place rather than given an untested
tools-stripping branch.

tests/test_meta_tool_choice.py covers required, named and auto, and asserts the
carve-out does not leak to other OpenAI-compatible endpoints. The LM Studio and
required-downgrade suites still pass unchanged.

Also documents the latency finding from the same run: Muse Spark reasons before
every reply, and reflect's 30s default deadline is too short for its final
synthesis — it timed out four times before failing. Raising
HINDSIGHT_API_REFLECT_LLM_TIMEOUT and HINDSIGHT_API_LLM_TIMEOUT to 300 makes
reflect return a correct grounded answer in ~60s.

Verified end to end on the fixed build: retain 26s (3 facts, entities and a
March 2026 temporal range), recall 1.7s (3 hits, correctly ranked), reflect 60s
(grounded answer), consolidation completed (4 observations, 15 links, 0 failed
operations).

* docs(models): give Meta Model API its own setup section with the required knobs

The provider's settings were inline comments inside the shared 20-provider
config block, which is the wrong place for something an operator must act on:
three of the four are required, not tuning, and one of them (the reflect
deadline) is the difference between reflect working and reflect returning
nothing at all.

Adds a "Meta Model API Setup" section alongside the other providers that need
one, with the required knobs as a table that states why each is required —
every one of them a consequence of Muse Spark always reasoning before it
replies. Also records the model lineup, the contributor-tier trade-off, and the
four things worth knowing up front: prompt caching is automatic (which is why
the capability table shows none), there is no batch or embeddings endpoint,
recursive JSON schemas are rejected, and calls are slow.

The shared config block keeps the two timeout exports, since they are required
to be set, and now points at the section for the reasoning.
2026-09-04 12:08:55 +02:00

109 lines
3.9 KiB
Python

"""Meta Model API rejects every ``tool_choice`` except ``"auto"``.
Verified live against https://api.meta.ai/v1 while wiring up the ``meta``
provider: reflect's forced first-turn retrieval came back as
HTTP 400 - only `"auto"` is supported for `tool_choice`. `"none"`,
`"required"`, and named function choices are not currently supported
which failed the whole reflect call rather than degrading it. This is the
opposite failure mode to LM Studio / Ollama (see test_lmstudio_tool_choice.py),
which accept the field and silently ignore it.
"""
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from hindsight_api.engine.llm_interface import (
LLM_TOOL_CHOICE_AUTO,
LLM_TOOL_CHOICE_REQUIRED,
LLMToolChoice,
)
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM
TOOLS = [
{
"type": "function",
"function": {
"name": "search_observations",
"description": "Search raw observations",
"parameters": {"type": "object", "properties": {"query": {"type": "string"}}},
},
},
{
"type": "function",
"function": {
"name": "recall",
"description": "Recall semantic memories",
"parameters": {"type": "object", "properties": {"query": {"type": "string"}}},
},
},
]
def _make_meta_llm() -> OpenAICompatibleLLM:
return OpenAICompatibleLLM(provider="meta", api_key="test-key", base_url="", model="muse-spark-1.3")
def _tool_call_response() -> MagicMock:
tc = MagicMock()
tc.id = "call_1"
tc.function.name = "search_observations"
tc.function.arguments = json.dumps({"query": "tooling preferences"})
resp = MagicMock()
resp.usage.prompt_tokens = 100
resp.usage.completion_tokens = 20
resp.usage.total_tokens = 120
resp.usage.completion_tokens_details = None
resp.choices[0].finish_reason = "tool_calls"
resp.choices[0].message.content = None
resp.choices[0].message.tool_calls = [tc]
return resp
async def _captured_params(llm: OpenAICompatibleLLM, tool_choice) -> dict:
with patch.object(llm._client.chat.completions, "create", new_callable=AsyncMock) as create:
create.return_value = _tool_call_response()
await llm.call_with_tools(
messages=[{"role": "user", "content": "What does the user prefer?"}],
tools=TOOLS,
tool_choice=tool_choice,
max_retries=0,
)
return create.call_args.kwargs
def test_meta_is_flagged_as_rejecting_non_auto_tool_choice():
assert _make_meta_llm()._rejects_non_auto_tool_choice() is True
def test_other_openai_compatible_providers_keep_the_required_contract():
"""The carve-out is Meta's alone — it must not weaken every compatible endpoint."""
other = OpenAICompatibleLLM(provider="openai", api_key="k", base_url="", model="gpt-5.6")
assert other._rejects_non_auto_tool_choice() is False
@pytest.mark.asyncio
async def test_meta_required_tool_choice_is_omitted():
"""``required`` would be a hard 400, so the field comes off the request."""
params = await _captured_params(_make_meta_llm(), LLM_TOOL_CHOICE_REQUIRED)
assert "tool_choice" not in params
@pytest.mark.asyncio
async def test_meta_named_tool_choice_is_omitted_but_still_narrows_the_tools():
"""Named choices stay practically forced: the tools list is filtered to the one."""
params = await _captured_params(_make_meta_llm(), LLMToolChoice.named("search_observations"))
assert "tool_choice" not in params
assert [t["function"]["name"] for t in params["tools"]] == ["search_observations"]
@pytest.mark.asyncio
async def test_meta_auto_tool_choice_sends_no_tool_choice_either():
"""``auto`` is the provider default and is already sent by omission."""
params = await _captured_params(_make_meta_llm(), LLM_TOOL_CHOICE_AUTO)
assert "tool_choice" not in params