Can LLM use tool calling when structured output is enabled?
An interactive model-communication inspector. One conversation at a time, with the wire visible.
┌─ CHAT ──────────────────────────┬─ WIRE ────────────────────────────┐
│ what a human reads: │ what actually went over the wire: │
│ your messages, the answer, │ the exact request JSON, every │
│ one line per tool call/result │ token as it arrives, usage, 400s │
└─────────────────────────────────┴───────────────────────────────────┘
Both panes are timestamped Y-m-d H:M:S, and each is written to its own file. Switch models
mid-conversation, toggle tools, switch how structured output is requested, watch cost accumulate.
It exists because the interesting questions about an LLM are rarely "is it good" — they are did it even see my tool, what did that schema turn into on the wire, why is this model silent. Those are invisible in a normal chat UI, and they are one glance away here.
python -m pip install -r requirements.txt # or: python -m pip install -e .
cp .env.example .env # add OPENAI_API_KEY and/or NEBIUS_API_KEY
python -m chatlab # http://127.0.0.1:8137The virtualenv lives outside this repo. Use whatever environment and interpreter you like —
nothing in here names one, and nothing writes inside the chatlab/ package.
python -m chatlab # web UI
python -m chatlab --tui # terminal renderer; /help for commands
python -m chatlab --refresh-models # re-fetch provider catalogues, then run
python -m chatlab --replay 'logs/wire-*.jsonl' # re-print a past session, spends nothingFlags are only per-launch things: --config --port --model --db --env-file --refresh-models --tui --replay -v. Everything else is a control you can click, which is why it is not a flag.
work_diris the directory holding thechatlab.yamlthat was loaded.
logs/, sandbox/, providers.yaml and data/ all resolve against it, so the app behaves the
same from any working directory and never writes inside the installed package. chatlab.yaml is
found by --config, then $CHATLAB_CONFIG, then ./chatlab.yaml, then the one beside the
package — so from a checkout, plain python -m chatlab just works.
Credentials come from the environment only. Search order: --env-file, $CHATLAB_ENV,
./.env. A real environment variable always wins, so OPENAI_API_KEY=sk-… python -m chatlab
needs no file. Never pass a key as an argument — argv is visible in the process table.
28 models ship with the app: 5 OpenAI + 23 Nebius, with price, context window, region and claimed capabilities. No network and no API key needed to start.
Every provider is an OpenAI-compatible /v1/chat/completions endpoint, so a provider is data,
not code — adding one is a YAML entry and nothing else:
# providers.yaml
providers:
- name: local-ollama
base_url: http://127.0.0.1:11434/v1/
api_key_env: OLLAMA_API_KEY
discover: false # this endpoint publishes no pricing; don't ask it
models:
- model: llama3.2
context_length: 131072
features: [tools]| file | what it is |
|---|---|
providers.yaml |
who you can talk to. Each entry is a base URL, a key env var, and either a hand-written model list (with pricing, because most vendors publish none) or discover: true to read /v1/models. |
data/catalog-<provider>.json |
the last discovery result for a discovering provider, bundled so the app starts offline. Refresh with --refresh-models [PROVIDER]. |
For a discovering provider, rows under models: are overlays: only the keys you actually
write are applied, so adding a note to a model never blanks the price discovery found for it.
Prices are USD per 1M tokens. An unknown price stays null and the cost column reads n/a — a
made-up number is worse than an absent one. pricing_unit is stated per provider rather than
sniffed, because per-token and per-million differ by a factor of a million and guessing wrong
silently is worse than showing nothing.
--refresh-models rewrites the snapshot in place, so a price change is a one-command update and
shows up in a diff. Region matters if you handle personal data: EU / UK / US are reported
separately and an unrecognised country code is reported as itself rather than guessed.
supported_featuresis a claim, not a measurement. Several models that do not liststructured_outputshandle schemas fine, and several that do list it still cannot combine a response format with tools. See below.
Measured across the catalogue, same prompt, same tool:
| model | claims structured_outputs |
off |
tool |
provider |
provider_final |
|---|---|---|---|---|---|
| gpt-4o-mini (OpenAI) | yes | 1 call | 2 calls | 1 call | 3 calls |
| zai-org/GLM-5.3-Flash | no | 1 call | 2 calls | 0 calls | — |
| Qwen/Qwen3.5-397B-A17B | no | 1 call | 2 calls | 0 calls | — |
| deepseek-ai/DeepSeek-V4-Flash | yes | 1 call | 2 calls | 0 calls | 3 calls |
| openai/gpt-oss-120b | yes | 1 call | 1 call | error | — |
provider mode sends response_format: {type: json_schema} alongside your tools. OpenAI can still
emit a tool call under that constraint. No Nebius model can — the schema constrains generation,
so the model narrates what it would query ("Let me check the database schema for you.") and never
calls anything. It fails silently: no error, no warning, just a model that seems to have
forgotten its tools.
19 raw requests to /v1/chat/completions, stdlib urllib, no LangChain in the way, same system
prompt and same run_sql schema chatlab itself sends. Against deepseek-ai/DeepSeek-V4-Flash-0731:
| request | tool calls |
|---|---|
tools, no response_format |
1 ✅ |
tools + json_schema |
0 |
tools + json_schema, strict: true |
0 |
tools + json_object |
0 |
tools + json_schema, strict removed from the tool |
0 |
tools + json_schema + tool_choice: "required" |
0 — silently ignored |
tools + json_schema + tool_choice: {name: run_sql} |
0 — silently ignored |
tools + json_schema, mid-loop (a tool result already in history) |
0 |
tools + json_schema, permissive schema (additionalProperties: true) |
0 |
no tools + json_schema |
n/a — schema honoured, so the JSON path is healthy |
So it is not the prompt, not LangChain's injected tool strict: true, not schema strictness, and
not json_schema specifically — any constrained decoding suppresses tool calls. Two rows say
it outright:
-
json_object+ tools makes the model emit{"sql_query": "SELECT name FROM sqlite_master ..."}as content. The tool-call intent survives; the grammar redirects it into the text channel. -
zai-org/GLM-5.3-Flashrefuses the combination in so many words:400 - tool_choice 'required' or a named tool cannot be combined with response_format, regex, or ebnf: the tool-call constraint and the output constraint cannot both be honored.DeepSeek ignores the same request silently and Qwen honours the
tool_choiceinstead of the schema (2 tool calls) — three backends, three behaviours, one underlying conflict.
There is no provider-side escape hatch. response_format: {type: "structural_tag"} is rejected
(422 - Input should be 'text', 'json_object' or 'json_schema'), and vLLM's own guided_json is
accepted and silently ignored — as is not_a_real_parameter_at_all, which is how we know.
Don't fight the grammar — stop sending it on the calls where a tool call is what you want.
provider_final runs every step of the loop unconstrained with the tools bound, and only once the
model stops asking for tools does it re-issue that same request under response_format with
tool_choice: "none". One extra model call per turn, on the last step.
provider 1 request tools + response_format -> narration, 0 tool calls
provider_final 3 requests tools -> run_sql(...)
tools -> draft, no tool call
tools + response_format + none -> {"response": "Categories, ..."}
^ carrying the draft + "return that as JSON"
That last call reformats; it does not re-answer. Without the draft in front of it the model
writes a fresh answer under the grammar, and a fresh answer is a much shorter one:
google/gemma-3-27b-it turned a four-point reply into "Hello! How can I help you debug today?" —
12% of the original. The two trailing messages are not cosmetic either; measured n=4 on two
providers:
| final-call shape | gemma-3-27b (Nebius) | gpt-4o-mini (OpenAI) |
|---|---|---|
| no draft, re-answer | shortened to 12% | preserved |
| draft as the last message | preserved | echoes the SCHEMA back, 4/4 |
| draft + "return that as JSON" | preserved | preserved |
| draft inside one user turn | HTTP 400 (wants role alternation) | preserved |
The last request is the payload every model in the table accepts. tools: [] would be tidier and
is not portable — Qwen/Qwen3.5-397B-A17B answers 400 - "tools" must not be an empty array. Either provide at least one tool or omit the field entirely, and OpenAI rejects it too; pinning
tool_choice keeps the body byte-identical to the request already measured to work.
It is stock LangChain — an AgentMiddleware (deferred.py) on create_agent(middleware=...), no
new dependency. If you want tools to work on a Nebius model, use provider_final (or tool,
or off).
One more trap, and it is not about tools: a grammar will not fill an optional field. Asked for a chart with
charts: Optional[list] = Nonein the schema, the model drew ASCII art insideresponseinstead — 0/5 times did it use the field. Make payload fields required with[]legal, and say so in the prompt.
provider_final defers the schema. The orchestrator never creates the conflict: it splits the
two mechanisms across two roles that each only ever do one thing.
orchestrator no tools, one structured output: sub-agent tools,
either "call these agents" or no response_format,
"here is the final answer" answers in prose
Nothing asks a model to do the impossible thing, so nothing needs a middleware and nothing depends
on LangChain internals that might move. One turn, list db tables, four requests:
POST · 2 msgs · response_format: json_schema -> delegate to sql
POST · 2 msgs · tools: run_sql -> run_sql(...)
POST · 4 msgs · tools: run_sql -> prose report
POST · 3 msgs · response_format: json_schema -> {"response": "Categories, ..."}
Toggle it with the orchestrated checkbox, or /orch in the TUI. It is orthogonal to the
structured mode, which still decides the shape of the final answer.
The union must be tagged. The action schema is a discriminated union on a kind field, not two
optional fields — measured, n=5 on two backends: tagged, 29/30 correct branch choices; two optional
fields, the model fills neither 9 times in 10. Same lesson as charts: Optional[...]: a grammar
takes the shortest valid path, and "both null" is the shortest one.
Sub-agents come from the enabled tool groups: sqlite → sql, fs → files. max_rounds bounds
the delegate→report loop.
Model · structured mode · either tool on/off · system prompt · temperature · max_tokens · reasoning_effort · recursion_limit · whether the optional schema fields are required.
Every control routes through Session.apply(), so the web UI and the TUI share one surface, and
every change is an event — it lands in both panes and both logs, in order. Your wire log shows
the moment you flipped a setting, sitting between the request that failed and the one that worked.
Re-run replays the last message against the currently selected model, after truncating history back to where that turn started — so the second model sees exactly what the first one saw.
One schema — response plus optional confidence, tags, sources — and seven ways of asking:
| mode | what goes on the wire |
|---|---|
off |
plain text |
auto |
a bare pydantic type; create_agent picks. It keys off the model name, so anything behind a base_url always lands on ToolStrategy regardless of what it supports |
provider |
response_format: {type: json_schema} — and it injects strict: true into your tool schemas too |
provider_final |
the same, but applied only to the call that ends the turn, so the tool loop runs unconstrained. That last call hands the draft back to be reformatted, not re-answered. One extra model call; the only structured mode in which tools work on Nebius |
tool |
the schema as a second tool, with tool_choice: "required" on every step — the model can never stop without calling something |
json_mode |
response_format: {type: json_object} + the schema in the prompt |
prompted |
format instructions in the prompt, parsed at the end |
"require confidence/tags/sources" makes the three optional fields mandatory — a quick way to see a model produce a perfectly good answer and have it rejected for missing bookkeeping.
- sqlite — read-only, over the bundled
data/northwind.db(--dborchatlab.yamlto point elsewhere). Read-only is the connection URI (file:…?mode=ro), not aSELECTprefix check: SQLite allowsWITH … INSERT, so the prefix check alone is bypassable and exists only to return a friendly message. Verified — a CTE-smuggled write comes backattempt to write a readonly database. The description names the dialect, because omitting it makes models emitINTERVAL '90 days'andDATEADD, which SQLite rejects. - fs —
list_files/read_file/write_fileinsidesandbox/(gitignored). Writes run immediately, no approval step. Escapes are refused by resolving both sides, which covers../and symlinks alike.
logs/chat-<stamp>.log mirrors the chat pane verbatim. logs/wire-<stamp>.jsonl
is one event per line — the pane's whole value is byte-exact JSON, and pretty text would either
truncate it or become a 600-line wall per turn that nothing can query. --replay renders it back
through the same function that drew the pane. Both files open with a header carrying the full
settings, so a log is self-describing a month later. Reset closes both and opens a new pair.
Per-token lines are coalesced into one response event per model call carrying
{ttft_s, tok_per_s, chunks, usage_reported}. Set log_tokens: true only to debug streaming itself.
Two honesty rules worth knowing when you read them:
usage_reported: falsemeans the provider sent no usage block and the token count is a chunk-count proxy, not a measurement.tok_per_s: nullmeans the step arrived as a single chunk, so there was nothing to measure between. Better than a confident49000000 tok/s.
A wire log contains every prompt you sent. logs/ is gitignored; keep it that way.
chatlab.yaml — default model, system prompt, sampling params, recursion_limit, structured
mode, tool paths, server host/port, log settings. Everything in it is also a control in the UI; the
file just sets where a session starts. Its directory is work_dir, so every path it contains is
relative to itself.
params.reasoning_effort: null means do not send it, which lets a model's own extra_args win.
That matters: gpt-5.6-luna carries reasoning_effort: "none" because without it every
tool-bound call returns
400 - Function tools with reasoning_effort are not supported for gpt-5.6-luna in
/v1/chat/completions. To use function tools, use /v1/responses or set
reasoning_effort to 'none'.
Precedence is providers.yaml extra_args < chatlab.yaml params < CLI < UI, and overriding a
model's own default is announced in the status line rather than done quietly.
chatlab.yaml session defaults -- and its directory is work_dir
providers.yaml who you can talk to, and what a call costs
data/ catalogue snapshots + the demo database
logs/ sandbox/ everything the app writes, never inside the package
chatlab/ code, plus the one asset it serves
events.py the Event dataclass + chat_line()/wire_line() -- the whole UI boundary
session.py state, the two logs, the fan-out; apply/run/rerun/reset
turn.py create_agent + astream -> timestamped events
wire.py the httpx request hook that captures the exact outbound body
catalog.py Provider/Candidate/ModelSpec, the bundled data, discovery, usage + cost
client.py init_chat_model with the capture client
structured.py the schema and the seven modes
deferred.py ProviderStrategy held back until the call that ends the turn
orchestrator.py a model with no tools, sub-agents with no schema
tools.py sqlite + fs
web.py FastAPI: one GET /events SSE stream, small POSTs in
tui.py the second renderer, stdlib only
static/index.html the entire UI: no npm, no build step, no framework
Session.emit() is sync and the renderers are pure Event -> str. The log files are a
renderer — chat.log is literally chat_line() appended to a handle. The TUI exists mainly to
keep that boundary honest: with one front-end, web-shaped assumptions drift into the core within a
week, and a consumer that imports only asyncio, session and events makes that a syntax error.
The web UI is one long-lived GET /events SSE stream plus small POSTs — not SSE-as-the-response-
to-the-POST. That is what lets a control click land in the stream as an ordered, timestamped event
while a turn is running, and it means EventSource does the reconnecting for us.
model.bind(stream_usage=True)does not survivecreate_agent:bind_toolsresolves throughRunnableBinding.__getattr__to the unwrapped model and drops it. It must be a constructor kwarg.- Passing
http_async_clientdisables langchain-openai's automaticstream_usage(it self-enables only when every client field isNoneand there is nobase_url). The wire capture and the token counts are coupled — setstream_usage=Trueexplicitly or every cost reads zero. stream_modemust be a list containing"messages". Drop it and the request silently becomes"stream": false— a wire-format switch, not a display preference.- Any
response_formatroutes the SDK throughbeta.chat.completions.stream(), which refuses non-strict tools.json_mode+ tools will raise; chatlab says so before you hit it. - Never
raise_for_status()in an httpx response hook — it destroysAPIStatusError.body.response.elapsedraises there too, so timing uses our own monotonic clock. recursion_limitbelongs in theRunnableConfig, not increate_agent. LangGraph's default is 25 super-steps, and one step can contain many parallel tool calls.- YAML 1.1 reads a bare
offas booleanFalse— hencestructured_mode: "off"is quoted.
- A side-by-side mode: same prompt, two models, two chat panes, one wire pane each.
- Record the tool/provider matrix above automatically, as a command, so it stays current as the catalogue changes.
- Token-level timing histogram — TTFT is recorded per call already; the shape of the gaps is where streaming stalls show up.