Code Agent Dissected (03): Different LLM Formats — How Does an Agent Unify the Interface?

A deep dive into MyCodeAgent's LLM interface layer: zero-dependency HTTP transport, provider routing table, and five-tuple response normalization. Understand why an agent avoids binding to a single SDK — and how splitting 'sending requests' from 'reading responses' into stable boundaries lets the ReAct loop see only a unified structure.

·9 min read·AI Engineering

The Problem Isn't "Can You Call an API?"

Switching to a different model is rarely as simple as changing one base_url:

  • DeepSeek, Zhipu, Kimi, and Qwen all claim to be "OpenAI-compatible," but their API key env var names, default endpoints, and tool_calls field positions aren't entirely the same
  • Some models still use the old function_call format; others return content as a multi-segment list
  • Certain backends restrict temperature, disallow multiple system messages, or don't recognize tool_choice=auto

If these differences are scattered throughout loop.py, the main loop turns into a swamp of if provider == xxx branches. MyCodeAgent's approach: lock away the differences inside core/llm.py and core/openai_compat.py, so the ReAct loop only ever sees a unified text and tool_calls.

The previous post covered how the loop runs turn by turn. This one looks at the boundary layer the loop crosses every time it actually calls a model.


Conclusion First

RuntimeRunner._react_loop()


HelloAgentsLLM.invoke_raw(messages, tools=...)
        │  ① Provider routing: select endpoint / key / default model
        │  ② Request normalization: temperature, multiple system messages, tool_choice quirks, etc.

OpenAICompatibleClient          ← stdlib urllib, no openai SDK
        │  POST {base_url}/chat/completions

raw response (object or dict, varies slightly by provider)


extract_* five-tuple             ← response normalization
  content / tool_calls / usage / meta / reasoning


loop consumes unified structure, continues Acting / Reasoning

Two layers, clear responsibilities:

LayerFileResponsibility
Transportopenai_compat.pySend HTTP, parse JSON/SSE, mimics client.chat.completions.create
Adaptationllm.pyProvider routing + request compatibility + response extraction

The loop deliberately calls invoke_raw() instead of invoke() — the former returns the full response object so tool_calls, usage, and finish_reason can be extracted later; the latter just yields a string, which is not enough for the main loop.


Layer One: Roll Your Own "Fake SDK"

The project has no dependency on the official openai package. openai_compat.py uses urllib to implement the minimal subset the harness actually needs:

# core/openai_compat.py — core call path
class OpenAICompatibleClient:
    def __init__(self, api_key, base_url, timeout):
        self.chat = _Chat(self)   # exposes client.chat.completions.create(...)
 
    def _create_completion(self, payload):
        if payload.get("stream"):
            return self._stream(payload)
        with self._request(payload) as response:
            return ResponseObject(json.loads(response.read().decode("utf-8")))

A request is a plain POST to {base_url}/chat/completions with a Bearer token header. Streaming reads SSE data: lines until [DONE].

The key piece is ResponseObject: it wraps a JSON dict into an object that supports dot-access like .choices[0].message, while also providing model_dump() to recover the raw dictionary.

class ResponseObject:
    def __getattr__(self, name):
        return _to_object(self._value[name])  # dict → dot-accessible object
 
    def model_dump(self):
        return self._value

Extraction functions higher up don't care whether the response came from the official SDK or this hand-rolled client — both attribute access and dict access work the same way.

Why skip the official SDK? Fewer dependencies; the entire critical path is visible in the repo. When something breaks, there's no third-party wrapper to suspect. The trade-off is implementing only the subset the harness needs, not a complete SDK.


Layer Two: How the Provider Gets Selected

HelloAgentsLLM.__init__ resolves configuration in a fixed priority order:

Explicit parameter: provider
    → Env var: LLM_PROVIDER
        → Auto-detection (detect_envs / URL markers)
            → Fall back to "auto" (generic OpenAI-compatible)

Credentials and default endpoints aren't hard-coded in if/else chains — they're looked up in the PROVIDER_PROFILES table:

# core/llm.py — table-driven routing (simplified)
PROVIDER_PROFILES = {
    "deepseek": {
        "key_envs": ("DEEPSEEK_API_KEY", "LLM_API_KEY"),
        "base_url": "https://api.deepseek.com",
        "model": "deepseek-chat",
        "url_markers": ("api.deepseek.com",),
        ...
    },
    "zhipu": { ... },
    "kimi": { ... },
    # ...
}
 
# During resolution:
self.provider = self._resolve_provider(provider, api_key, base_url)
self.api_key, resolved_base_url = self._resolve_credentials(api_key, base_url)

Set LLM_PROVIDER=zhipu and the code fetches Zhipu's default base_url and the relevant key env vars directly from the table — no domain names hard-coded in business logic.

Auto-detection also has a safety valve: if keys for multiple providers are detected simultaneously, it raises an error and requires an explicit choice — preventing the silent "you think you're calling A, but you're hitting B" failure.


Layer Three: Response Normalization (The Most Valuable Layer)

After receiving raw_response, the loop does exactly this:

# runtime/loop.py — main loop consumes unified fields
response_text = extract_response_content(raw_response) or ""
reasoning_content = extract_reasoning_content(raw_response)
usage = extract_usage(raw_response)
response_meta = extract_response_meta(raw_response)
tool_calls = extract_tool_calls(raw_response)

Five functions project "slightly-different JSON from each provider" into the harness's stable shape. The one the loop depends on most is extract_tool_calls:

# core/llm.py — normalize old and new Function Calling formats
def extract_tool_calls(response):
    message = _response_message(response)
 
    # New format: message.tool_calls[] (mainstream)
    calls = response_attr(message, "tool_calls") or []
    if calls:
        return [{"id": ..., "name": ..., "arguments": ...} for call in calls]
 
    # Old format: message.function_call (single invocation)
    function_call = response_attr(message, "function_call")
    if function_call:
        return [{"id": None, "name": ..., "arguments": ...}]
 
    return []

Regardless of whether the model returns tool_calls or function_call, the loop always receives [{id, name, arguments}]. The "has tool_calls → execute tool" branch in the Acting step from the previous post is built on top of this normalization.

The other four functions each own a slice:

FunctionWhat It ExtractsHow the Loop Uses It
extract_response_contentBody text (handles list-style content)Written to history / candidate answer for completion gate
extract_tool_callsList of tool invocationsActing branch
extract_usageprompt/completion/total tokensTrack usage, check token budget
extract_response_metafinish_reason, length, refusal, etc.Empty-response retry, truncation detection, tracing
extract_reasoning_contentChain-of-thought (optional field)Debug display; does not affect control flow

The generic attribute reader is a single line, yet it underpins the entire extraction logic:

def response_attr(value, key):
    # dict → .get; object → getattr — ResponseObject and official SDK share the same extraction code
    return value.get(key) if isinstance(value, dict) else getattr(value, key, None)

The Request Side Has Compatibility Patches Too

Normalization doesn't only happen on the response. Before sending, _build_request() smooths over several known quirks:

  1. Kimi K2 / 2.5: Only accepts temperature=1; other values are automatically replaced and a warning is logged
  2. MiniMax: Merges multiple system messages; drops tool_choice=auto (not recognized)
  3. Accidentally-full paths: If base_url was set to .../chat/completions, the suffix is stripped before constructing the client

All of this logic lives in the adaptation layer. The loop still just passes messages / tools / tool_choice. When a new "mostly-compatible but quirky" backend needs to be added, the rule goes here — not into the main loop.

Retries also live here: _invoke_with_retries wraps non-streaming calls with LLM_MAX_RETRIES plus exponential backoff. Note this is distinct from the "inner while model error recovery" described in the previous post — that layer handles semantic-level recovery like empty responses or PROMPT_TOO_LONG; this layer handles transient network/HTTP failures.


Three Entry Points — the Loop Uses One

MethodReturnsUsed By
invoke_raw()Raw response objectMain loop (then passed to extract_*)
invoke()Plain text stringSummarization and other "just give me a sentence" scenarios
think() / stream_invoke()Streaming text chunksUser-facing streaming output

The main loop chooses invoke_raw deliberately: an agent needs more than "what did the model say" — it also needs "were there tool calls, why did it stop, how many tokens were used." The string entry point throws all of that away.


Design Highlights

  1. Transport separated from adaptation: HTTP mechanics in openai_compat, business compatibility in llm, the loop touches neither
  2. Table-driven providers: Adding a new model is mostly adding a row to PROVIDER_PROFILES, not modifying the call chain
  3. Project rather than rewrite: extract_* only reads the raw response; it never mutates the provider's original payload — full snapshots remain available for tracing
  4. Zero SDK dependency: The critical path is fully visible and portable; the "fake SDK shape" keeps call patterns consistent without the external dependency

Summary

MechanismPurpose
OpenAICompatibleClientMinimal OpenAI-compatible transport via stdlib
PROVIDER_PROFILESTable-driven routing for endpoints, keys, and default models
invoke_raw + extract_*Main loop consumes only unified text / tool_calls / meta
Old/new tool format compatibilitytool_calls and function_call normalized to the same list shape
Request-side compatibility patchestemperature, multiple system messages, malformed URLs — all handled centrally

The next post follows "how does the model know which tools are available" deeper: where tool schemas come from, and how Function Calling arguments flow into the execution pipeline.


About the Source Code for This Series

All analysis in this series is based on the open-source project MyCodeAgent.

The source code includes inline comments at key locations, aligned with the order topics are introduced in this series — you can follow along with the code while reading, or clone it and run, modify, and extend it to build your own agent.

git clone https://github.com/chendongqi/MyCodeAgent
cd MyCodeAgent
cp .env.example .env   # fill in your LLM API key
uv sync
uv run python main.py

Visit PrimeSkills — a curated AI Agent and skills marketplace where every piece of content is validated against real enterprise workflows. No hype, just things that actually work.

For more practical insights and interesting products, visit my personal homepage.