Laminate: one extract() for every provider's tool calls

OpenAI, Anthropic, and Ollama return tool-call arguments in three shapes. Laminate's provider adapters normalize them in Rust, so one extract() call reads every provider.

GL
Greg Lamberson
· 7 min read

When a tool-calling LLM API returns a tool call, the arguments come back in a shape that depends on which provider answered. OpenAI puts them in a JSON string. Anthropic puts them in an object. Ollama puts them in an object under keys of its own. The logical call is the same; the envelope isn't. The code that reads the arguments shouldn't have to care which provider sent them, and that's the whole job here: Laminate's provider adapters normalize the three envelopes into one shape, so a single extract() call reads the arguments the same way for every provider.

That's the idea, so let's earn it.

Three providers, three shapes, one argument

Say the model decided to call get_weather with a city and some units. Here's what lands on the wire, depending on where the request went.

OpenAI, and most endpoints that advertise an OpenAI-compatible API, put the arguments in a JSON string, not an object:

Show the code
"tool_calls": [{
  "id": "call_1",
  "type": "function",
  "function": { "name": "get_weather", "arguments": "{\"city\":\"London\",\"units\":\"celsius\"}" }
}]

Anthropic puts them in an object, under a different key:

Show the code
"content": [{
  "type": "tool_use",
  "id": "tu_1",
  "name": "get_weather",
  "input": { "city": "London", "units": "celsius" }
}]

Ollama, called natively, also uses an object, under keys of its own:

Show the code
"message": {
  "tool_calls": [{ "function": { "name": "get_weather", "arguments": { "city": "London", "units": "celsius" } } }]
}

Same call, same argument, three envelopes. With a strict typed client, that's three deserializations to write, and three to keep writing as the shapes drift. The endpoints most people actually run against make it worse, not better: A local model through Ollama's /v1, a gateway, a self-hosted vLLM server. "OpenAI-compatible" is a spectrum out there, not a guarantee. One backend adds a field. Another reports a finish_reason outside OpenAI's documented set. A third omits an optional field a client expected. A per-provider deserialization is a standing maintenance cost, and a finish_reason your enum has never heard of is a parse error waiting to happen.

Shape it once, read it the same way

Each provider gets a small adapter that normalizes its response into one type, NormalizedResponse, and the arguments arrive as a FlexValue, a navigable wrapper over the parsed JSON. The three shapes converge before any of your code sees them:

%% Three provider responses (OpenAI stringified arguments, Anthropic object, Ollama object) pass through their adapters into one NormalizedResponse with a ToolUse block, then a single input.extract call. flowchart TD A["OpenAI / OpenAI-compatible<br/>arguments: JSON string"] --> P1["parse_openai_response"] B["Anthropic<br/>input: object"] --> P2["parse_anthropic_response"] C["Ollama (native)<br/>arguments: object"] --> P3["parse_ollama_response"] P1 --> N["NormalizedResponse<br/>ContentBlock::ToolUse { id, name, input: FlexValue }"] P2 --> N P3 --> N N --> E["input.extract(&quot;city&quot;)<br/>the same call for every provider"]
Three provider responses (OpenAI stringified arguments, Anthropic object, Ollama object) pass through their adapters into one NormalizedResponse with a ToolUse block, then a single input.extract call.

The adapter does the envelope work, and that includes OpenAI's one real type difference. OpenAI ships arguments as a string of JSON, so parse_openai_response parses that string into an object for you. Anthropic and Ollama already send an object. By the time you hold input, all three are the same navigable thing:

Show the code
  OpenAI       function.arguments = "{\"city\":\"London\"}"   (a JSON string)
                 parse_openai_response parses it  →  input = { "city": "London" }
  Anthropic    input = { "city": "London" }                   (already an object)
  Ollama       function.arguments = { "city": "London" }      (already an object)

  then, for all three:   input.extract("city")  →  "London"

From there, the reading code is identical:

Show the code
use laminate::provider::openai::parse_openai_response;
// or: anthropic::parse_anthropic_response, ollama::parse_ollama_response

let resp = parse_openai_response(&body)?;

for block in &resp.content {
    if let Some((_id, name, input)) = block.as_tool_use() {
        let city: String  = input.extract("city")?;
        let units: String = input.extract("units")?;
        dispatch(name, city, units);
    }
}

Swap parse_openai_response for the Anthropic or Ollama function, and nothing else changes. input.extract("city") is the same call against all three. Run the three real fixtures through it, and they land identically:

Show the code
[openai]    name=get_weather  city=London  units=celsius
[anthropic] name=get_weather  city=London  units=celsius
[ollama]    name=get_weather  city=London  units=celsius

When the response drifts

A real OpenAI-compatible response from a gateway or a local model often doesn't match OpenAI's documented shape exactly. It carries a field OpenAI never sends. It reports a finish_reason outside OpenAI's set, like eos_token from a vLLM or TGI backend. It omits an optional field a client expected. Here's a response with all three, plus the stringified arguments and a numeric argument the model sent as a string:

Show the code
{
  "id": "chatcmpl-x",
  "model": "some-openai-compatible-model",
  "x_provider_note": "openai-compatible gateway",
  "choices": [{
    "message": {
      "tool_calls": [{
        "id": "call_1", "type": "function",
        "function": { "name": "get_weather", "arguments": "{\"city\":\"London\",\"max_results\":\"5\"}" }
      }]
    },
    "finish_reason": "eos_token"
  }]
}

Parsing it is uneventful, which is the point:

Show the code
let resp = parse_openai_response(&body)?;       // the extra field is read past, not tripped over
let (_id, name, input) = resp.content.iter()
    .find_map(|b| b.as_tool_use())
    .expect("a tool call");

let city: String = input.extract("city")?;          // "London"
let max:  u32    = input.extract("max_results")?;    // "5" coerces to 5
println!("{name} {city} {max} stop_reason={}", resp.stop_reason);
// get_weather London 5 stop_reason=eos_token

Three things happened without ceremony. The unexpected x_provider_note field was read past, because the adapter reads the fields it needs and leaves the rest alone. The eos_token finish reason, which isn't in OpenAI's documented set, came through as a recognized "unknown" stop reason instead of an error, so you can match on it rather than catch it. And max_results, sent as the string "5", coerced to the u32 the code asked for, because extract is forgiving about value types by default.

The stop reason is normalized across providers, not just tolerated. OpenAI's finish_reason, Anthropic's stop_reason, and Ollama's done_reason all arrive as one StopReason, and a value none of them documents yet is kept as Unknown(String) instead of dropped or rejected. A new backend's invented reason is a value you can match on, not a parse failure to chase.

There's a line worth stating plainly. Laminate normalizes envelopes and reads real JSON whose structure is close to what you expected. Genuine JSON-syntax repair, a malformed body with a trailing comma or an unquoted key, belongs to a separate pass. The adapter's job is the shape of the response, not the syntax of the bytes.

Where it fits

This is the envelope half of the problem: One reader for every provider's tool calls, forgiving about the drift around the edges, clear about the stop reason whatever the backend called it. The arguments arrive as a FlexValue, and extract reads them the same way no matter who sent them.

When those arguments also need to become a typed struct, with per-field coercion and a record of every change, that's the derive, and it's the subject of the companion post. When you control both ends of the wire, serde's strictness is exactly right, and there's nothing to normalize. cargo add laminate with the providers feature, point one extract() at every provider, and the tool calls stop being a per-provider chore.


Appendix: The provider shapes, side by side

Three providers express the same logical response with different field paths and one real type difference, the arguments. The adapters map each into one NormalizedResponse, so this table is the part you don't have to memorize.

OpenAI / OpenAI-compatible Anthropic Ollama (native)
Assistant text choices[].message.content (string or null) content[] {type: "text"} message.content
Tool call choices[].message.tool_calls[] content[] {type: "tool_use"} message.tool_calls[]
Tool name function.name name function.name
Tool arguments function.arguments, a JSON string input, an object function.arguments, an object
Stop reason field finish_reason stop_reason done_reason
Stop reason values stop, length, tool_calls, content_filter end_turn, tool_use, max_tokens, stop_sequence stop, length, backend-specific
Usage prompt_tokens, completion_tokens input_tokens, output_tokens, cache_* prompt_eval_count, eval_count
Streaming SSE chunks named SSE events newline-delimited JSON

Every row becomes the same thing on Laminate's side. The envelope and its parts, as defined:

Show the code
pub struct NormalizedResponse {
    pub id: String,
    pub model: String,
    pub content: Vec<ContentBlock>,
    pub stop_reason: StopReason,
    pub usage: Usage,
    pub raw: FlexValue,         // the original response, always accessible
}

pub enum ContentBlock {
    Text { text: String },
    ToolUse { id: String, name: String, input: FlexValue },
    Unknown { block_type: String, data: FlexValue },    // forward-compatible
}

pub enum StopReason {
    EndTurn,
    ToolUse,
    MaxTokens,
    StopSequence,
    Unknown(String),           // a value none of the four providers documented
}

The accessors do the matching, so the calling code stays flat:

Show the code
block.as_tool_use()   // Option<(&str, &str, &FlexValue)>  ->  (id, name, input)
block.as_text()       // Option<&str>
resp.tool_uses()      // Vec<&ContentBlock>
resp.has_tool_use()   // bool
resp.text()           // String: every text block concatenated

GL
Greg Lamberson

Founder of Lamco Development LLC. Building Linux infrastructure: a Wayland-native RDP server, a memory-safe UEFI bootloader, and open-source Rust and Kotlin libraries.

More about Greg ›