mirror of
https://github.com/vectorize-io/hindsight.git
synced 2026-09-14 19:31:49 +08:00
30ce3b8d11
* 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.
109 lines
3.9 KiB
Python
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
|