unsloth/studio/backend/tests/test_external_tools_compat.py
Daniel Han 0e158a8b74
Studio: run the local tool loop against every capable external provider (#8665)
* Studio: run the local tool loop against every capable external provider

Studio's tools execute on the Studio host, not on the model, but only one
external provider could use them: the gate was a hardcoded
provider_type == "openai_codex" in routes/inference.py and again in
research_runs.py. Point Studio at your own llama.cpp, vLLM, Ollama or
OpenRouter and Search, Code, MCP, Docs and Deep Research all greyed out.

Generalise the capability instead of adding a second implementation:

- providers.py declares studio_tools per provider type and
  provider_runs_local_tools() replaces the hardcoded comparison. Hidden
  entries now reach /registry flagged rather than filtered out, which is
  why the UI could never learn the self-hosted presets are capable.
- The loop moves to core/inference/studio_tool_loop.py behind a Transport
  protocol. CodexTransport keeps the app-server call; OAICompatTransport
  wraps ExternalProviderClient.stream_chat_completion, the one function
  every provider re-yields through.
- Self-hosted models often write a call as text instead of emitting
  structured tool_calls, so the loop feeds content through
  StreamToolCallHealer, the same bounded buffer the client-tool
  passthrough uses. Only a partial-signal window is ever withheld and an
  unparseable block flushes verbatim, so a turn that never terminates
  renders its text instead of nothing.
- Deep Research accepts any capable saved connection. Research hops keep
  tool_choice "none" and enabled_tools [], so a prompt-injected page in
  the evidence set still cannot reach python.
- response_format now reaches OpenAI-compatible providers at all; JSON
  mode was silently dropped for every one of them.

Anthropic is deliberately excluded: _stream_anthropic never forwards a
caller's function-tool schemas, so the loop would advertise a catalog the
model never sees. It needs schema plus tool_use/tool_result translation.

The local, GGUF, safetensors and MLX paths are untouched -- every hunk in
routes/inference.py is inside _proxy_to_external_provider. Codex behaviour
is unchanged, and the healer is only constructed when a tool catalog is
actually selected.

Fixes #7282. Fixes #7761.

Also fixes a live bug: chat-runtime-store disarmed Deep Research for any
external model id including openai_codex, which the backend and the menu
both permit, so Codex users had to re-enable it after every model switch.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: carry the local loops' execution behaviour into the external one

The first cut generalised the Codex loop, which never had to deal with a
model that writes a call as text, repeats one, or stalls. Everything the
llama.cpp and safetensors loops learned about that is now shared here,
taken from the work in #8630:

- ToolLoopController owns dedup, one-shot withdrawal and the
  force-final-answer transition, so a repeated call cannot spend the
  budget twice.
- stream_tool_execution streams live stdout with heartbeats, so a long
  python or terminal call cannot idle the stream out.
- The approval card is flushed on its own write before the stream blocks,
  so Allow and Deny paint immediately in the desktop app.
- A stalled model that says what it will do gets one nudge to do it.
- Usage is summed and reported once instead of a burst of partial counts.
- append_assistant_turn merges a continued partial, and replayed content
  is stripped of markup since the call replays structurally.

Fixes found by testing the above:

- An intermediate turn's [DONE] was relayed, so a spec-compliant client
  stopped before the tool cards and the final answer.
- A structured call was dropped unless finish_reason was exactly
  tool_calls. Ollama and several proxies close with stop, or omit it.
  A truncated turn (finish_reason length) still never executes.
- Argument fragments without an index started a phantom call and ran the
  real one with empty arguments.
- Two distinct calls at one index merged into unparseable JSON.
- max_tool_calls_per_message capped what was advertised, not what ran: a
  provider that called anyway was executed. It is a safety limit, so it
  now gates execution, per call rather than per turn.
- Provider turns are bounded, not just executions. A model asking for
  tools that can never run cannot trade turns forever.
- Call ids are unique for the whole run, so a provider that restarts its
  numbering cannot put two results under one id.
- An empty tool_calls list no longer counts as grammar mode working and
  disables healing for the rest of the turn.
- A call with no id is given one instead of being silently dropped.
- The provider stream is closed from this frame rather than left to the
  async-generator finalisation hook.
- Content parts reach the replayed assistant message, not just the client.

Compatibility: /api/providers/registry hides the backend-only entries
again by default and takes include_hidden=true, so a browser holding a
pre-capability bundle sees exactly the list it saw before instead of
rendering the self-hosted presets as duplicate options.

Also fixes the frontend build: thread.tsx used
providerModelSupportsStudioTools without importing it.

* Apply ruff formatting

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: carry structured output, budgets and Gemini signatures through the external loop

Forward response_format into the native Gemini and OpenAI Responses request
shapes so a deep research planning hop still gets JSON mode; Gemini takes a
generationConfig responseMimeType and only on a tool-free turn, Responses takes
text.format.

Keep an explicit max_tool_calls_per_message of 0 meaning disabled instead of
falling back to 25, refuse a non-scalar providerType with a 400 rather than a
registry TypeError, recompute continue_final_message per turn so a post-tool
request no longer resumes a role=tool message, replay each call's Gemini
thought signature on the assistant turn, restore round_id on every tool card,
and let a verified internal workflow key resolve the run's saved connection.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: stop the external loop on cancel, pair overflow calls, keep hosted images

Race the provider read against the cancel flag in OAICompatTransport, so Stop
and a model load close the upstream instead of waiting for the next chunk;
stream_chat_completion takes no cancel_event and the cancel routes only set the
flag, so nothing else could reach the socket.

Replay a call that overflowed the tool budget in the assistant message that
carries its result, since a role=tool message with no matching tool_call is
rejected outright by a strict server. Let the unlimited sentinel keep the local
loops' bound rather than a fixed 25-turn headroom that truncated a long run.
Forward the provider-hosted tools the loop has no local equivalent for, so the
Images toggle still works on a turn that also enables a Studio tool.

* Studio: close the tool card the loop announced but then skipped

The external loop relays the provider's own tool_calls delta, so the client
paints a card for every call the model announces. A call the controller then
declines to run (a repeat of one that already succeeded, a disabled tool,
a second render_html) emitted no terminal event, so that card kept spinning
for the rest of the answer and settled as a tool that ran and returned
nothing.

Emit a tool_end for it, the same way the budget-exhausted branch already
does, and key both on the id the provider streamed: a repeated call is
exactly the one the loop renames for the replayed history, so the renamed
id belongs to no card.

* Studio: scope the research credential path and route research off the saved provider row

Authorize the saved-connection path on the Deep Research workflow key alone
rather than on any internally minted key: a data-recipe job also holds one and
can read provider ids, so the broader check would have let it spend an
unrelated saved credential. The workflow name is set by Studio at mint time and
read back from the stored key row.

Validate the research connection's capability on the saved provider type and
persist it, instead of comparing it to the client's UI label. A legacy custom
connection still stored as backend type openai reports custom, vllm or ollama
in the UI, so the equality check refused a connection the chat route drives.

* Studio: fix the upgrade and integrity gaps found reviewing the external tool loop

Review of the external-provider tool loop turned up several things that were
either wrong before this branch or introduced by it. Each one is pinned by a
test that fails without the fix.

A provider endpoint could forge Studio's own tool cards. The loop writes its
tool_start / tool_end frames onto the same SSE stream it relays provider chunks
on, and the client tells them apart by shape, so an endpoint could paint a
completed python card, sourced local, for code that never ran. base_url is
user-configurable, so this needed a boundary: provider lines are stripped of
Studio's control vocabulary at every relay site.

The pre-change bundle asks for provider-hosted tools as enable_tools plus
enabled_tools ["web_search", "code_execution"], which the widened gate captured,
dropping hosted code_execution and silently swapping hosted search for local.
Providers that run builtins themselves now declare hosted_tools in the registry,
and a request naming only those stays on the hosted passthrough. The Code pill
keeps a connection's own sandbox rather than relocating execution to this
machine as a side effect of an update.

A truncated turn discarded a healed tool call, and because promotion strips the
markup from the relayed text, the user lost the call and the text describing it.
The healer now records the promoted span so a discarded call releases what the
model actually wrote.

A budget-exhausted call appended a role=tool message with no matching assistant
tool_calls entry, which OpenAI, DeepSeek and strict vLLM reject as invalid
history, and closed a UI card that was never opened.

Durable research compared the saved row's provider type against the type the
client sent. Self-hosted connections are stored as openai and surfaced as
llama_cpp, vllm, ollama or custom, so that check rejected exactly the
connections the path exists to serve. It validates the saved row now.

The saved-credential exception is scoped to the Deep Research workflow key
rather than to any internally minted key, so a data-recipe subprocess key cannot
spend a saved cloud credential.

Pill state keyed on hosted builtins, so a self-hosted connection dropped the
user's saved Search and Code preference on every reload and sent
enable_tools: false while the composer left the pills clickable.

The schema guard asserted an exact column set, so it failed on any unrelated
column main added later. It now asserts what this change actually owns: the
pre-existing columns stay readable and the capability is not persisted.

* Studio: replay Responses reasoning, converge rolled-back capabilities, keep the resolved model id

Three follow-ups from the same review pass.

OpenAI's Responses API requires reasoning items to be passed back alongside the
function call and its output; omitting them costs accuracy and the prompt-cache
hit, and a reasoning item with nothing after it is rejected outright. Studio
already captured those items but only the image-generation card read them, so a
local tool call dropped them. They are replayed now, re-sanitized because the
recorded copy carries a status the API refuses on input, buffered so a trailing
reasoning item is never emitted, and carrying encrypted_content for orgs whose
ids resolve to nothing.

Capability sync only ever wrote rows the registry returned, so a provider that
disappeared after a backend rollback kept studio_tools true in localStorage
forever. Persisted capabilities are pruned to what the backend still reports.
Clearing is the safe direction: an unknown capability reads as null, which every
caller treats as not capable.

The summed usage chunk reported the model the request asked for. OpenRouter and
similar resolve a routing alias to a concrete model and name it on every chunk,
which is the more specific answer, so that id wins.

* Studio: accept both streamed tool-name dialects, count usage once, stop draining a cancelled tool

Streamed tool-call names arrive two ways and the loop handled only one at a
time. llama-server re-sends the whole name as it grows, so appending produced
webweb_search; OpenAI sends fragments that continue it, so assigning produced
_search. Either way the name misses the enabled-tool check and the call silently
never runs. A fragment that already starts with what we have is the name resent,
anything else continues it.

Usage hanging off a chunk that also carries a choice was relayed intact and then
counted again in the summed chunk the loop emits at the end, so a client that
adds up chunks double-counted the turn. The chunk cannot be withheld wholesale
without losing its content, so only the usage block is dropped.

A tool that does not watch the cancel event kept being asked for heartbeats
after a Stop, holding the answer open. The consumer stops requesting them and
lets the existing drain join the worker under its own bounded timeout.

* Studio: close the card a truncated provider turn left open

A turn that streams tool_call fragments and then stops at finish_reason
"length" has already put a card on the client, and the loop is right to
refuse a call whose arguments are cut off mid-write. It left that card open
for the rest of the response though, so it read as a tool that ran and
returned nothing.

Close it through the same unrun-call helper the budget and skipped paths
use. Structured calls only: a call the healer recovered from text was never
streamed, and the released markup span is what tells the user about that one.

Also make the leaked-approval-slot assertion order independent. state
.tool_approvals._pending is process global, and a sibling module that drives
the route through the confirm gate leaves an entry behind, so asserting the
whole registry is empty made that test pass or fail on collection order.
Compare against the slots the test did not open instead.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: keep a retained hosted tool's result out of the loop's sanitizer

ExternalProviderClient strips Studio's control vocabulary from every raw
upstream line at the point it arrives, then synthesizes _toolEvent chunks of its
own for a provider-hosted tool. The loop sanitized a second time, where a
synthesized frame is no longer distinguishable from a forged one, so a hosted
image or web-search result was dropped after the provider had already produced
and billed it. A transport now declares whether it has already sanitized, and
only one that has not is sanitized here.

* Studio: offer the Code pill only where code can actually run

The pill was enabled from the general Studio-tools flag, so a model on a
sandbox-owning provider that cannot use that sandbox, an OpenAI o3 for
instance, lit it and restored a stored preference. The placement rule
deliberately runs nothing there rather than relocating the work onto the
user's machine, so a Code-only send became enable_tools: false and answered
without executing anything.

Read the pill out of the placement rule itself, which makes it impossible for
what the composer offers and what the request carries to disagree.

* Studio: enforce tool_choice none, refuse a filtered turn, keep encrypted reasoning

tool_choice "none" was advertised outbound but never enforced on the way back, so
a provider that emitted a call anyway had it executed. Deep Research sets "none"
precisely so the scraped third-party text its hops carry cannot reach python or
terminal, which makes this the one place the instruction has to be a rule rather
than a hint. A call arriving under "none" is now refused.

A turn ending in content_filter was treated as executable. Like length, it means
the model never finished saying what it wanted, so the arguments collected so far
can be half written. Both are refused now. "stop" deliberately stays executable:
llama.cpp and vLLM routinely end a perfectly good tool call with it, and refusing
those would disable tool calling on the self-hosted servers this path is for.

A pre-existing test asserted the reasoning replay sanitizer strips
encrypted_content. It should not: a zero-data-retention org has store=false
forced on it, so the item id resolves to nothing server side and the encrypted
blob is the only thing carrying the model's reasoning state into the next
request. The assertion was an exact dict match whose stated subject was the
status field, so it now checks that and leaves the rest alone.

* Apply the repo's kwarg-spacing formatter to the new refusal-gate tests

* Studio: report a stopped tool honestly, survive nested arguments, skip Gemini image models

A tool interrupted by Stop or a disconnect fell through to an empty result,
which record_result then reported as a successful call with no output. The
transcript claimed the tool ran and produced nothing, when it was abandoned
partway and its side effects may already have happened. Cancellation now says so.

Deeply nested but syntactically valid JSON in function.arguments raises
RecursionError, which is not a ValueError, so it escaped the loop after the
provider's tool-call delta had already been relayed and surfaced as a
stream-level server error. It is treated as unparseable input like any other
decoding failure.

Gemini's image models drop the function catalog inside the native translator
(text_tools_allowed is false for them), so the provider-wide capability flag was
advertising a loop those models cannot participate in: the MCP and Docs pills lit
up and the turn completed as though nothing had been selected. The gate is
model-aware now and leaves them on the plain proxy.

---------

Co-authored-by: danielhanchen <unslothai@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-13 07:37:23 -07:00

432 lines
16 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Upgrade / version-skew guards for the studio-tools-on-every-provider change.
These tests do not exercise the tool loop itself (``test_studio_tool_loop.py``
owns that). They pin the contract at the seams where an *existing* install can
break during an upgrade, because each of those seams is a place where the two
halves of Studio are versioned independently:
* the ``/api/providers/registry`` payload, read by a browser that may still be
running a JS bundle from before this capability existed (old FE + new BE);
* the ``ProviderRegistryEntry`` schema, which a new bundle parses from a
backend that may predate the new fields (new FE + old BE);
* the ``llm_providers`` sqlite schema, which this change must not migrate;
* ``response_format``, newly forwarded on the OpenAI-compatible path, which
must stay opt-in because not every OpenAI-compatible server tolerates it.
"""
import asyncio
import json
import sqlite3
import httpx
import pytest
from core.inference import external_provider as ep_mod
from core.inference.external_provider import ExternalProviderClient
from core.inference.providers import (
PROVIDER_REGISTRY,
list_available_providers,
provider_runs_local_tools,
)
# ── helpers ──────────────────────────────────────────────────────────
def _drive(coro):
return asyncio.new_event_loop().run_until_complete(coro)
async def _collect(agen):
return [line async for line in agen]
def _mock_http_client(monkeypatch, handler):
transport = httpx.MockTransport(handler)
monkeypatch.setattr(ep_mod, "_http_client", httpx.AsyncClient(transport = transport))
def _capturing_handler(captured: dict):
def handler(request: httpx.Request) -> httpx.Response:
captured["body"] = json.loads(request.content.decode("utf-8"))
return httpx.Response(
200,
content = b'data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n',
headers = {"content-type": "text/event-stream"},
)
return handler
# The four self-hosted presets. They are ``hidden`` in the registry and are
# surfaced by the UI through CUSTOM_PROVIDER_PRESETS rather than the dropdown.
SELF_HOSTED_PRESETS = ("custom", "vllm", "ollama", "llama_cpp")
# Keys the pre-change bundle already read off every registry row. Dropping or
# renaming any of them breaks a cached bundle even though the server is new.
LEGACY_REGISTRY_KEYS = frozenset(
{
"provider_type",
"display_name",
"base_url",
"default_models",
"model_capabilities",
"supports_streaming",
"supports_vision",
"supports_tool_calling",
"model_list_mode",
"auth_kind",
"base_url_editable",
"model_ids_editable",
}
)
# ── 1a. old frontend + new backend ───────────────────────────────────
def test_registry_default_still_hides_self_hosted_presets():
"""The default payload is byte-for-byte the *set* the old bundle expected.
A browser holding a pre-change bundle filters the provider dropdown on a
hardcoded ``HIDDEN_PROVIDER_TYPES`` set that contains only ``qwen``; it has
no idea to filter on a ``hidden`` field. If the default response started
including the self-hosted presets, that bundle would render vLLM / Ollama /
llama.cpp / Custom as four extra dropdown entries duplicating the custom
presets it already lists above the separator. Hence: opt-in.
"""
types = {entry["provider_type"] for entry in list_available_providers()}
for preset in SELF_HOSTED_PRESETS:
assert preset not in types, (
f"{preset} is hidden and must not appear in the default /registry "
"payload; a cached pre-change bundle would render it as a duplicate "
"dropdown entry"
)
def test_registry_include_hidden_returns_presets_flagged():
"""``include_hidden=true`` is how a bundle that *does* know asks."""
entries = {
entry["provider_type"]: entry for entry in list_available_providers(include_hidden = True)
}
for preset in SELF_HOSTED_PRESETS:
assert preset in entries, f"{preset} missing from include_hidden payload"
assert entries[preset]["hidden"] is True
assert entries[preset]["supports_studio_tools"] is True
def test_hidden_flag_matches_the_registry_source_of_truth():
"""Every row's ``hidden`` mirrors the registry, so the UI filter is total."""
for entry in list_available_providers(include_hidden = True):
expected = bool(PROVIDER_REGISTRY[entry["provider_type"]].get("hidden"))
assert entry["hidden"] is expected
def test_visible_rows_are_identical_with_and_without_include_hidden():
"""Asking for hidden rows must not perturb the rows the old bundle reads."""
default_rows = list_available_providers()
widened = {
entry["provider_type"]: entry for entry in list_available_providers(include_hidden = True)
}
for row in default_rows:
assert row == widened[row["provider_type"]]
def test_registry_rows_keep_every_pre_change_key():
"""Additive only. A cached bundle reads these keys off every row."""
for entry in list_available_providers(include_hidden = True):
missing = LEGACY_REGISTRY_KEYS - set(entry)
assert not missing, f"{entry['provider_type']} lost legacy keys {missing}"
# ── 1b. new frontend + old backend ───────────────────────────────────
def test_registry_entry_schema_tolerates_a_pre_change_payload():
"""A new bundle against an old backend gets no ``supports_studio_tools``.
The pydantic model must default it to False rather than reject the row, so
the capability degrades *closed*: pills stay off instead of arming a tool
loop the old backend cannot run.
"""
from models.providers import ProviderRegistryEntry
legacy_payload = {
"provider_type": "openai",
"display_name": "OpenAI",
"base_url": "https://api.openai.com/v1",
"default_models": ["gpt-4o"],
"supports_streaming": True,
"supports_vision": True,
"supports_tool_calling": True,
}
entry = ProviderRegistryEntry(**legacy_payload)
assert entry.supports_studio_tools is False
assert entry.hidden is False
# ── capability allowlist ─────────────────────────────────────────────
def test_anthropic_is_not_studio_tools_capable():
"""``_stream_anthropic`` never forwards caller function-tool schemas.
Advertising the capability would hand the loop a catalog the model never
sees, so every turn would look like a model that declined to call a tool.
"""
assert provider_runs_local_tools("anthropic") is False
def test_openai_codex_keeps_the_capability_it_already_had():
"""The pre-change behaviour is a strict subset of the new one."""
assert provider_runs_local_tools("openai_codex") is True
@pytest.mark.parametrize("provider_type", SELF_HOSTED_PRESETS)
def test_self_hosted_presets_run_studio_tools(provider_type):
assert provider_runs_local_tools(provider_type) is True
@pytest.mark.parametrize("provider_type", [None, "", "not_a_provider", " "])
def test_unknown_provider_types_degrade_closed(provider_type):
"""An unrecognised type must never arm the loop."""
assert provider_runs_local_tools(provider_type) is False
def test_capability_flag_agrees_with_the_registry_entry():
for entry in list_available_providers(include_hidden = True):
assert entry["supports_studio_tools"] is provider_runs_local_tools(entry["provider_type"])
# ── 1c. no DB migration ──────────────────────────────────────────────
def test_llm_providers_schema_gains_no_column():
"""Existing sqlite rows need no migration; the capability is not persisted.
It is derived from the registry at read time, so an install upgrading in
place keeps its ``llm_providers`` rows verbatim.
Asserted as "the pre-existing columns are all still there, and this change
added none of its own" rather than as an exact snapshot of the table. An
exact snapshot fails on any unrelated column main adds later (it already
would on ``max_output_tokens``), which says nothing about whether this
change needs a migration and would only train people to update the literal.
"""
from storage import providers_db
conn = sqlite3.connect(":memory:")
try:
providers_db._ensure_schema(conn)
columns = {row[1] for row in conn.execute("PRAGMA table_info(llm_providers)")}
finally:
conn.close()
# Every column a pre-change row was written with must still be readable.
assert columns >= {
"id",
"provider_type",
"display_name",
"base_url",
"is_enabled",
"created_at",
"updated_at",
"models_json",
"available_models_json",
}
# The capability must stay registry-derived. A column here would mean saved
# connections carry their own copy, which needs a migration story this
# change deliberately does not have.
assert not [
column
for column in columns
if "studio_tool" in column or "local_tool" in column or "tool_execution" in column
]
# ── 4. response_format stays opt-in ──────────────────────────────────
def test_response_format_is_omitted_when_the_caller_does_not_ask(monkeypatch):
"""Not every OpenAI-compatible server tolerates ``response_format``.
TGI types it as a Rust enum with no ``text`` variant and 422s on the
OpenAI-default ``{"type": "text"}``; LM Studio before 0.3.18 400s on the
same. Studio talks to those through the ``custom`` preset, so the field has
to stay absent unless a caller explicitly asked for structured output.
"""
captured: dict = {}
_mock_http_client(monkeypatch, _capturing_handler(captured))
async def run():
client = ExternalProviderClient(
provider_type = "custom",
base_url = "http://custom.example/v1",
api_key = "",
)
await _collect(
client.stream_chat_completion(
messages = [{"role": "user", "content": "ping"}],
model = "local-model",
temperature = 0.7,
top_p = 0.95,
max_tokens = 64,
)
)
await client.close()
_drive(run())
assert "response_format" not in captured["body"]
def test_response_format_is_forwarded_verbatim_when_requested(monkeypatch):
"""Structured-output requests used to be dropped silently on this path."""
captured: dict = {}
_mock_http_client(monkeypatch, _capturing_handler(captured))
async def run():
client = ExternalProviderClient(
provider_type = "custom",
base_url = "http://custom.example/v1",
api_key = "",
)
await _collect(
client.stream_chat_completion(
messages = [{"role": "user", "content": "ping"}],
model = "local-model",
temperature = 0.7,
top_p = 0.95,
max_tokens = 64,
response_format = {"type": "json_object"},
)
)
await client.close()
_drive(run())
assert captured["body"]["response_format"] == {"type": "json_object"}
# ── 5. response_format reaches the native provider shapes ────────────
def test_gemini_translates_response_format_to_a_response_mime_type(monkeypatch):
"""Deep research plans on Gemini now, and its planning hop asks for JSON.
Gemini never sees ``response_format``; it is a generationConfig MIME type,
so dropping it left the planner parsing prose.
"""
captured: dict = {}
_mock_http_client(monkeypatch, _capturing_handler(captured))
async def run():
client = ExternalProviderClient(
provider_type = "gemini",
base_url = "https://generativelanguage.googleapis.com/v1beta",
api_key = "k",
)
await _collect(
client.stream_chat_completion(
messages = [{"role": "user", "content": "Return only strict JSON"}],
model = "gemini-3-pro",
tool_choice = "none",
enabled_tools = [],
response_format = {"type": "json_object"},
)
)
await client.close()
_drive(run())
assert captured["body"]["generationConfig"]["responseMimeType"] == "application/json"
assert "tools" not in captured["body"]
def test_gemini_skips_the_json_mime_type_when_tools_are_sent(monkeypatch):
"""Gemini 400s on "Function calling with a response mime type ... unsupported"."""
captured: dict = {}
_mock_http_client(monkeypatch, _capturing_handler(captured))
async def run():
client = ExternalProviderClient(
provider_type = "gemini",
base_url = "https://generativelanguage.googleapis.com/v1beta",
api_key = "k",
)
await _collect(
client.stream_chat_completion(
messages = [{"role": "user", "content": "hi"}],
model = "gemini-3-pro",
tools = [
{
"type": "function",
"function": {"name": "web_search", "parameters": {"type": "object"}},
}
],
tool_choice = "auto",
response_format = {"type": "json_object"},
)
)
await client.close()
_drive(run())
assert "tools" in captured["body"]
assert "responseMimeType" not in captured["body"].get("generationConfig", {})
@pytest.mark.parametrize(
"response_format, expected",
[
({"type": "json_object"}, {"type": "json_object"}),
(
{
"type": "json_schema",
"json_schema": {
"name": "plan",
"schema": {"type": "object", "properties": {}},
"strict": True,
},
},
{
"type": "json_schema",
"name": "plan",
"schema": {"type": "object", "properties": {}},
"strict": True,
},
),
],
)
def test_openai_responses_translates_response_format_to_text_format(
monkeypatch, response_format, expected
):
"""/v1/responses carries structured output on ``text.format``, never response_format."""
captured: dict = {}
_mock_http_client(monkeypatch, _capturing_handler(captured))
async def run():
client = ExternalProviderClient(
provider_type = "openai",
base_url = "https://api.openai.com/v1",
api_key = "k",
)
await _collect(
client.stream_chat_completion(
messages = [{"role": "user", "content": "Return only strict JSON"}],
model = "gpt-5.1",
response_format = response_format,
)
)
await client.close()
_drive(run())
assert captured["body"]["text"]["format"] == expected
assert "response_format" not in captured["body"]
def test_a_non_scalar_provider_type_is_not_a_registry_lookup_crash():
"""The value arrives straight from a request body; dict.get would TypeError."""
assert provider_runs_local_tools(["vllm"]) is False
assert provider_runs_local_tools({"provider": "vllm"}) is False
assert provider_runs_local_tools(None) is False
assert provider_runs_local_tools("vllm") is True