Skip to main content

Provider detection

FIM One uses LiteLLM as a universal adapter. The _resolve_litellm_model() function in core/model/openai_compatible.py maps the user’s LLM_BASE_URL + LLM_MODEL to a LiteLLM model identifier with a provider prefix. The prefix determines how LiteLLM routes the request — native API protocol (Anthropic Messages API, Gemini, etc.) or generic OpenAI-compatible /v1/chat/completions. Resolution order:
  1. Explicit provider (from DB ModelConfig.provider field) — highest priority. If the provider matches a known domain in the URL, no api_base is returned (LiteLLM routes natively). Otherwise, api_base is set to the relay URL.
  2. Domain match against KNOWN_DOMAINS — official API endpoints are recognized by hostname.
  3. URL path hint against PATH_PROVIDER_HINTS — common on relay platforms like UniAPI where /claude or /anthropic in the path indicates the upstream protocol.
  4. Fallbackopenai/ prefix (generic OpenAI-compatible).
When the provider prefix is a native protocol (anthropic, gemini, etc.) and the URL is not the official endpoint, LiteLLM uses the native protocol but sends requests to the relay’s api_base. This means provider-specific behaviors — including the Bedrock prefill issue described below — apply regardless of whether the request goes to the official API or through a relay.
If your relay URL contains /claude in the path, FIM One automatically routes via Anthropic’s native protocol. This is usually correct (better streaming, thinking support), but means provider-specific behaviors apply — including the Bedrock prefill issue described below.

tool_choice — the four modes

The tool_choice parameter is standardized via the OpenAI format. LiteLLM translates it to each provider’s native protocol before sending the request. The distinction between "auto" and forced ({"type":"function",...}) is the crux of every compatibility issue in FIM One. These two modes are used by completely different subsystems with different requirements.

Where tool_choice is used

Two subsystems use tool_choice, and they use it in fundamentally different ways.

ReAct engine — tool_choice=“auto”

The ReAct loop needs the model to decide each iteration: call a tool, or give a final answer. Only "auto" makes sense here — the model freely chooses between producing tool_calls or text content. This is compatible with all providers, all models, and all modes including extended thinking. The ReAct engine uses native function calling (_run_native) when abilities["tool_call"] = True, falling back to JSON-in-content mode (_run_json) otherwise. Both modes use "auto" — the difference is whether tools are passed via the tools parameter or described in the system prompt. See ReAct Engine — Dual-mode execution for details.

structured_llm_call — tool_choice=forced

One-shot structured extraction (schema annotation, DAG planning, plan analysis). Forces the model to call a specific virtual function, guaranteeing structured JSON output. This is the call site that triggers provider-specific errors. structured_llm_call implements a 3-level degradation chain: The critical design difference: structured_llm_call’s fallback is runtime — it dynamically tries each level and catches exceptions to fall through. The ReAct engine’s mode selection is build-time — it checks _native_mode_active once at the start and commits to one mode for the entire loop. This means structured_llm_call can recover from provider-specific 400 errors transparently, while ReAct relies on the mode being correctly chosen upfront.

The Bedrock prefill trap

When response_format={"type":"json_object"} is passed for a model resolved with the anthropic/ prefix, LiteLLM internally injects an assistant prefill message to simulate JSON mode. The Anthropic Messages API has no native response_format parameter, so LiteLLM approximates it by prepending an opening brace as assistant content:
This works on Anthropic’s direct API. However, newer AWS Bedrock model versions reject any conversation whose last message has role: "assistant" — they call this “assistant message prefill” and throw:
This error occurs only when all three conditions are met simultaneously:
  1. The model is resolved with the anthropic/ prefix (via domain match or URL path hint).
  2. response_format={"type":"json_object"} is passed (the json_mode code path in structured_llm_call).
  3. The actual backend is AWS Bedrock (which rejects prefill).
Bedrock via OpenAI-compatible endpoint? If your Bedrock relay exposes an OpenAI-compatible /v1/chat/completions endpoint (either AWS’s own OpenAI-compatible gateway or a third-party proxy), and the URL path does not contain /claude or /anthropic, FIM One resolves it with the openai/ prefix. LiteLLM then treats the backend as a standard OpenAI-compatible server, passes response_format directly without injecting any prefill, and the server handles JSON constraining natively. The prefill trap does not apply — you do not need to set json_mode_enabled=false.
This does NOT affect native tool calling (tool_choice="auto" with tools= parameter). The prefill injection only happens for response_format. ReAct agent execution is completely unaffected.
If both Level 1 (native_fc) and Level 2 (json_mode) fail on Bedrock, the system recovers at Level 3 (plain_text). The json_mode_enabled flag described below eliminates the wasted Level 2 call.

The fix: json_mode_enabled

A per-model json_mode_enabled flag controls whether Level 2 (json_mode) is ever attempted:
  • DB-configured models: toggle in Admin → Models → Advanced settings. The flag is stored on ModelProviderModel.json_mode_enabled (default TRUE).
  • ENV-configured models: set LLM_JSON_MODE_ENABLED=false in your environment.
  • Effect: when disabled, abilities["json_mode"] returns Falseresponse_format is never passed → no prefill → Bedrock works. The degradation chain becomes native_fc → plain_text, skipping the doomed json_mode call entirely.
  • No quality loss: the model still returns valid JSON because the system prompt instructs it to. The plain_text level uses extract_json() to parse JSON from free-form content, which works reliably with modern models.

Thinking models + forced tool_choice

Several providers reject a forced tool_choice while extended thinking is active, on the grounds that pinning a specific function call contradicts the model’s freedom to reason first:
This is a per-provider rule, not a law of thinking models. Anthropic enforces it at the protocol level and Moonshot (Kimi) behaves the same way, but MiniMax thinks on every call and still accepts a forced tool choice. Table B in the Provider Capability Matrix records the verdict provider by provider; do not generalise from one row to another. For Anthropic models, structured_llm_call resolves the conflict on its own by passing reasoning_effort=None on the native-FC level, which turns thinking off for that one call (structured.py::_call_llm). Structured output needs schema compliance, not deep reasoning, so disabling thinking there is both correct and cheaper. Where thinking cannot be switched off through the API, native_fc fails with a 400 on every structured call and costs roughly ten seconds before the chain falls through to json_mode. Kimi is the common case: with thinking on only auto is supported, and a forced tool choice requires turning thinking off, which Moonshot exposes only through the model id (kimi-k2 has it off, kimi-k2.5 and kimi-k2-thinking have it on). FIM One has no parameter that flips it, so the remedy is the tool_choice_enabled flag below.

The fix: tool_choice_enabled

A per-model tool_choice_enabled flag controls whether Level 1 (native_fc) is ever attempted:
  • DB-configured models: toggle in Admin → Models → Advanced → “Native Function Calling”. The flag is stored on ModelProviderModel.tool_choice_enabled (default TRUE).
  • ENV-configured models: set LLM_TOOL_CHOICE_ENABLED=false in your environment.
  • Effect: when disabled, abilities["tool_choice"] returns False → the degradation chain starts from Level 2 (json_mode) or Level 3 (plain_text), skipping native_fc entirely. This eliminates the ~10s penalty per structured call for incompatible models.
  • ReAct agent unaffected: tool_choice_enabled only controls forced tool selection in structured_llm_call. The ReAct engine uses tool_choice="auto" (model freely decides), which works with all models regardless of this setting.
tool_choice_enabled and tool_call are separate ability flags. tool_call (always True for OpenAICompatibleLLM) gates whether tools are passed to the model at all — disabling it would break the ReAct agent. tool_choice only gates whether forced tool selection is attempted for structured output extraction.
tool_choice="auto" is unaffected by thinking mode. The ReAct engine uses "auto" exclusively, so agent execution works with thinking enabled.
Do NOT set abilities["tool_call"] = False to avoid this constraint. That would disable ReAct’s _run_native mode (which uses tool_choice="auto" and works fine with thinking), forcing it into the less reliable _run_json mode.
Provider migration note: Some third-party relays silently drop unsupported parameters like reasoning_effort (drop_params=True), so thinking is never activated even when configured. When migrating to a provider that properly supports thinking (Bedrock, direct Anthropic API), the reasoning_effort=None in native_fc ensures consistent behavior. No user action is needed — structured output works identically across all providers.

Provider Capability Matrix

This section is the authoritative record of what each provider supports and what FIM One does about it. Every row names the function that implements the behaviour, so any claim here can be checked against the code. Other pages link here instead of repeating the data; when the code changes, this section changes with it. A row describes a provider’s protocol, not a single model. Where models inside one family differ (DeepSeek chat against reasoner, Kimi with thinking on against off), the cell says so.

Table A: Protocol routing

How a configured base_url plus model becomes a LiteLLM call, and what happens when the first choice of interface is unavailable. How GPT-5.x picks a protocol. FIM_GPT5_RESPONSES_MODE selects it: native (the default) talks /v1/responses directly through litellm.aresponses, bridge uses LiteLLM’s chat-completions translation, and off forces plain chat completions. The native path exists because the bridge is lossy in the one place that matters: it discards the reasoning items, so a GPT-5.x agent re-derives its chain of thought on every tool round. Talking the protocol directly lets those items be replayed. A call that explicitly passes reasoning_effort=None, which is what structured_llm_call and the finish-signal probes do, stays on chat completions, because a call that wants no thinking has no reasoning state to preserve. Two properties of that native request are load-bearing and easy to get wrong:
  • store=false keeps the conversation stateless upstream, and include=["reasoning.encrypted_content"] asks for the encrypted payload to be returned. Without the include, the reasoning items arrive empty and replay silently becomes a no-op.
  • A replayed reasoning item must have its server-side id stripped. With store=false nothing is persisted upstream, so echoing the id back earns Item with id 'rs_...' not found. Items are not persisted when 'store' is set to false. The encrypted_content blob carries the state on its own, so dropping the id costs nothing (sanitize_reasoning_item).
Bedrock. Bedrock-hosted Claude follows the row it resolves to, not the fact that Bedrock is hosting it. Reached through an anthropic/-routed relay it inherits Anthropic protocol behaviour, including LiteLLM’s json-mode assistant prefill, which newer Bedrock versions reject. Reached through an OpenAI-compatible gateway it resolves as openai/, no prefill is injected, and json_mode_enabled can stay on.

Table B: Conflicts and workarounds

FIM One emits three of the four tool_choice states: auto from the ReAct loop (react.py::_run_native), a named function from structured output (structured.py::_call_llm), and none when the finish-signal answer replays the tools payload. No call site emits required; that column records the provider’s constraint on non-auto tool choice, which applies to required and to a named function alike.

Table C: Thinking protocol

LLM_REASONING_EFFORT accepts low, medium and high; any other value is read as unset (deps.py::_reasoning_effort). What FIM One then puts on the wire is per-provider, and that is what this table records. The replay column is the return value of reasoning_replay_policy, which is a small closed set of four states rather than a per-provider list. unsupported and informational_only produce the same bytes on the wire: both strip reasoning_content and signature from outgoing history. They differ in intent, so a model that clearly does reason but lands in unsupported is a gap in the fragment table rather than a live bug.

Relay/proxy gotchas

Third-party gateways fail in ways a direct provider does not, and most of those failures are silent. Each row below pairs the symptom with its mechanism and with what FIM One already does about it.
Support boundary. FIM One guarantees the behaviour documented on this page for first-party endpoints: OpenAI’s own API, Anthropic, Google, and any vendor serving its own models directly. Third-party relays are supported on a best-effort basis and are not covered by that guarantee, because what a relay does to a request is outside our control and frequently outside its own documentation. A relay can drop a parameter, rewrite history, strip a cache breakpoint, or answer a protocol it only partially implements, and in most of those cases it returns a 200 rather than an error.This is a statement about what we promise, not a restriction on what runs. FIM One does not maintain an allowlist of approved hosts, and nothing here is gated on a domain. Capability is decided by what an endpoint actually does: a missing route answers 404 and is remembered, an ignored include yields empty reasoning items and the replay becomes a no-op, and a rejected request falls back for that call. Probing the endpoint is more accurate than inferring its capabilities from its hostname, and it is the only approach that keeps working for Azure OpenAI, enterprise gateways, and self-hosted proxies that implement the protocol correctly.If a relay misbehaves in a way the fallbacks do not catch, pin the protocol yourself with FIM_GPT5_RESPONSES_MODE (bridge or off) or the per-model tool_choice_enabled and json_mode_enabled toggles, and reproduce against the first-party endpoint before filing it as a FIM One bug.
Both tool_choice_enabled and json_mode_enabled can be toggled per model in Admin → Models → Advanced settings. The defaults, both TRUE, are correct for most providers; only adjust when you see errors or wasted latency. Which providers need an adjustment is recorded in Table B above, and the per-model view an operator fills in lives in Model Management.
When to change: if you see structured_llm_call: native_fc call raised warnings in your logs followed by successful json_mode extraction, the model does not benefit from native_fc. Disable “Native Function Calling” for that model to eliminate the wasted API call (~10s per structured output request).
ENV-level overrides apply to all models configured via environment variables (not admin UI):

Reasoning effort and thinking configuration

FIM One exposes two env vars for controlling extended thinking / reasoning: Two behaviours follow automatically once thinking is on, and neither needs user configuration:
  1. Temperature is handled for you. On an anthropic/ route with thinking active, _build_request_kwargs pins temperature to 1.0, which is what Bedrock demands. Models that reject sampling parameters outright (Opus 4.7 and 4.8, Fable 5, Mythos 5) have temperature removed from the request entirely, thinking or not. Do not set LLM_TEMPERATURE=1 by hand for this.
  2. GPT-5.x keeps tools and reasoning together where it can. FIM One probes the Responses bridge first for GPT-5.x, because that is the only surface where the two combine. An endpoint with no usable /v1/responses route falls back to chat completions, the verdict is cached per endpoint, and on that path a request carrying tools sends an explicit reasoning_effort of none. Omitting the field is not equivalent, since the server default is not none.

Defensive parsing for structured output

Even with native_fc working correctly, the structured output pipeline includes a defensive parsing layer to handle edge cases from any provider or compatibility layer. The DAG planner’s _dict_to_steps parser handles three common edge cases:
  1. Single object instead of array. Some models return {"steps": {"id": "1", "task": "..."}} (a single step object) instead of {"steps": [{"id": "1", "task": "..."}]} (an array). The parser detects this by checking for id or task keys and wraps the object in a list.
  2. Double-encoded JSON string. When structured output falls through to json_mode (which lacks schema enforcement), some providers return the steps value as a JSON string rather than a native array — e.g., {"steps": "[{\"id\": \"1\", ...}]"}. This string may also contain literal newlines (from the model’s formatting) that break standard json.loads. The parser uses extract_json_value() (which includes _repair_json_strings) to handle:
    • Literal newlines inside JSON string values
    • Invalid escape sequences (common with LaTeX or code content)
    • Other serialization quirks from compatibility layers
  3. Missing steps wrapper. The model may return a single step as the top-level object without the steps wrapper key. The parser detects id and task at the root level and wraps accordingly.
Under normal operation, native_fc returns properly structured tool call arguments and these edge cases do not arise. The defensive parsers exist as a safety net for custom BaseLLM subclasses, unusual provider behaviors, or fallback scenarios where structured output degrades to json_mode or plain_text.

Prompt caching (cross-provider)

FIM One implements Anthropic’s explicit prompt caching via cache_control breakpoints and simultaneously benefits every other provider’s automatic prefix caching through the Prompt Section Registry. The goal is a single prompt-assembly path that works across all providers without per-call prompt shape divergence.

Architecture

The fim_one.core.prompt module exposes three primitives:
  • PromptSection — a named fragment with either a static content: str or a dynamic content: Callable
  • PromptRegistry — a memoized store (static sections render once, dynamic sections re-render per call)
  • DYNAMIC_BOUNDARY — a sentinel marker the registry inserts between the last static section and the first dynamic one, so callers can split the rendered prompt at the cache breakpoint
System prompts for ReAct (JSON mode, native function-calling mode, synthesis) are split into:
  • Static prefix (~95% of the prompt) — identity, core guidelines, tool descriptions
  • Dynamic suffix — current datetime, per-request language directive, handoff context

Capability detection

fim_one.core.prompt.caching.is_cache_capable(model_id) returns True when the model id contains any of: claude, anthropic, bedrock/anthropic, vertex_ai/claude. These providers receive two role="system" messages with cache_control: {"type": "ephemeral"} on the first (static) message. Every other provider receives a single concatenated system message with no cache_control field — necessary because non-Anthropic endpoints either reject the field or silently drop it, and sending it through some relays causes 400 unknown parameter errors.

Cross-provider coverage

The PromptRegistry benefits every provider with auto prefix caching “for free” — by keeping the static portion byte-identical across calls (current datetime lives in the dynamic suffix, not prefix), every auto-caching provider’s hash matches and hits their cache. This is why the Registry is a foundational modelless win even before considering Anthropic-specific cache_control.

Observability

Every chat/* response’s done_payload now includes:
TurnProfiler emits a structured log line per turn: turn_cache summary | model=claude-sonnet-4-6 | read_tokens=1067 | create_tokens=0 | saved_input_tokens=961 (~90%). This also functions as a relay honesty probe — if you route through an API relay, compare actual billed input vs read_tokens to detect whether the relay strips cache_control or keeps the 0.10× discount. No dollar estimate is returned at the LLM layer — pricing and relay markup are applied above, so the LLM layer only returns objective token counts.

Multi-turn cache ROI

Measured on Claude 4 ReAct turns with the default agent prompt: A 10-iteration ReAct run with 10 tools saves ~8,640 input tokens per turn after the first (9 cache hits × 1067 tokens × 90%). Anthropic charges 1.25× for cache write on the first call, so the breakeven is at the second call — single-shot queries do not benefit.

Reasoning replay policy (modelless correctness)

Extended thinking / reasoning blocks behave differently across providers. A uniform serialization policy breaks both protocol contracts and automatic prefix caches. fim_one.core.prompt.reasoning.reasoning_replay_policy(model_id) returns one of four values and gates ChatMessage.to_openai_dict(replay_policy=...) in OpenAICompatibleLLM._build_request_kwargs().

Four policies

  • anthropic_thinking — Claude family (including anthropic/, bedrock/anthropic, vertex_ai/claude). Thinking blocks MUST be replayed with signature attached; Anthropic rejects subsequent turns if the signature is missing or altered.
  • informational_only — models that emit CoT but do NOT expect replay: DeepSeek reasoning mode (deepseek-reasoner on V3.2, and the older deepseek-r1 and R1-Distill ids the fragment table still matches), Qwen QwQ, Gemini flash-thinking, OpenAI o1 / o3 / o4. Their documentation explicitly says “do not send reasoning_content back in message history”. Sending it anyway:
    • Violates the provider contract (may start rejecting in future versions)
    • Silently invalidates their automatic prefix cache — message bytes mutate on every turn, breaking the hash
  • openai_responses — GPT-5.x, matched on the gpt-5 fragment. Its reasoning state is not text but a sequence of opaque items carrying encrypted payloads, and only /v1/responses has a slot for them. On that protocol the items are replayed verbatim, which is what keeps the model’s chain of thought alive across tool rounds. The readable summary is still dropped from outgoing requests, so on the chat-completions fallback this behaves exactly like informational_only. Checked before the informational fragments, whose generic reasoning entry would otherwise swallow proxy-tagged GPT-5 ids.
  • unsupported — the catch-all: models with no reasoning capability (GPT-4o, Gemini 1.5, Mistral, Llama), and reasoning models whose id matches no fragment (GLM, MiniMax, Kimi, Doubao). No field should be replayed either way, so this policy puts the same bytes on the wire as informational_only. It is also the safe default for unknown model ids.
The readable reasoning_content and the opaque reasoning_items are independent fields on ChatMessage. to_openai_dict() never serialises the items at all, so they are structurally incapable of leaking onto a chat-completions request, whatever the policy says.

Enforcement

All policy evaluation happens in one place (_build_request_kwargs). ChatMessage.to_openai_dict(replay_policy=None) preserves the A3 permissive default so uncoordinated callers don’t regress. The cross-provider test matrix lives in tests/test_reasoning_replay_policy.py with reverse assertions proving that non-Anthropic requests do NOT leak reasoning_content.

For users

Both feature and bug behavior is automatic — you don’t need to configure anything. Workflow implications:
  • If you switch agents between Claude and DeepSeek in the same conversation, history is stored with thinking blocks intact; on the next turn, the outgoing message shape adapts per the current model.
  • If you use a proxy / custom BaseLLM subclass, make sure its model id is recognizable (contains one of the fragments) or the default unsupported policy will apply — which is safe but means Claude behind an unusual proxy might lose thinking replay. Add the model-id fragment to _CACHE_CAPABLE_MODEL_FRAGMENTS (in core/prompt/caching.py) and/or the reasoning policy lookup.

Troubleshooting

“This model does not support assistant message prefill” Bedrock + json_mode. Two fixes: (1) set LLM_JSON_MODE_ENABLED=false or disable JSON Mode in the admin model settings; or (2) if your Bedrock provider offers an OpenAI-compatible /v1/chat/completions endpoint, switch to that — FIM One resolves it as openai/ and the prefill injection never occurs. “Thinking may not be enabled when tool_choice forces tool use” / “tool_choice ‘specified’ is incompatible with thinking enabled” For Anthropic models, structured_llm_call disables thinking for native_fc calls automatically. Where thinking cannot be turned off through the API, such as kimi-k2.5 and kimi-k2-thinking or deepseek-reasoner, disable “Native Function Calling” in the model’s Advanced settings, or set LLM_TOOL_CHOICE_ENABLED=false globally. The degradation chain will skip native_fc and extract structured output via json_mode or plain_text instead. Check Table B of the Provider Capability Matrix before assuming a thinking model has this problem; MiniMax does not. “DAG pipeline failed: LLM ‘steps’ is not an array” The LLM returned the steps field as a string or single object instead of an array. This typically means structured output fell through to json_mode (which lacks schema enforcement). Check the log for structured_llm_call: level=xxx — if it shows json_mode instead of native_fc, native_fc is failing silently. If using a custom BaseLLM subclass, verify it accepts the reasoning_effort kwarg. ReAct falls back to JSON mode unexpectedly Check that the model’s abilities["tool_call"] is True. This is always True for OpenAICompatibleLLM, but a custom BaseLLM subclass might override it. Verify with the model detail endpoint in the admin API. structured_llm_call exhausts all levels and raises StructuredOutputError The model failed to produce parseable JSON at any level. This is rare with modern models. Check: (1) the schema is valid JSON Schema, (2) the model has enough max_tokens to produce the full response, (3) the system prompt is not contradicting the schema instructions. The DAG planner and analyzer both provide default_value fallbacks, so this error only propagates from call sites that explicitly omit defaults.