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_callsfield positions aren't entirely the same - Some models still use the old
function_callformat; others return content as a multi-segment list - Certain backends restrict
temperature, disallow multiple system messages, or don't recognizetool_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 / ReasoningTwo layers, clear responsibilities:
| Layer | File | Responsibility |
|---|---|---|
| Transport | openai_compat.py | Send HTTP, parse JSON/SSE, mimics client.chat.completions.create |
| Adaptation | llm.py | Provider 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._valueExtraction 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:
| Function | What It Extracts | How the Loop Uses It |
|---|---|---|
extract_response_content | Body text (handles list-style content) | Written to history / candidate answer for completion gate |
extract_tool_calls | List of tool invocations | Acting branch |
extract_usage | prompt/completion/total tokens | Track usage, check token budget |
extract_response_meta | finish_reason, length, refusal, etc. | Empty-response retry, truncation detection, tracing |
extract_reasoning_content | Chain-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:
- Kimi K2 / 2.5: Only accepts
temperature=1; other values are automatically replaced and a warning is logged - MiniMax: Merges multiple system messages; drops
tool_choice=auto(not recognized) - Accidentally-full paths: If
base_urlwas 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
| Method | Returns | Used By |
|---|---|---|
invoke_raw() | Raw response object | Main loop (then passed to extract_*) |
invoke() | Plain text string | Summarization and other "just give me a sentence" scenarios |
think() / stream_invoke() | Streaming text chunks | User-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
- Transport separated from adaptation: HTTP mechanics in
openai_compat, business compatibility inllm, the loop touches neither - Table-driven providers: Adding a new model is mostly adding a row to
PROVIDER_PROFILES, not modifying the call chain - Project rather than rewrite:
extract_*only reads the raw response; it never mutates the provider's original payload — full snapshots remain available for tracing - Zero SDK dependency: The critical path is fully visible and portable; the "fake SDK shape" keeps call patterns consistent without the external dependency
Summary
| Mechanism | Purpose |
|---|---|
OpenAICompatibleClient | Minimal OpenAI-compatible transport via stdlib |
PROVIDER_PROFILES | Table-driven routing for endpoints, keys, and default models |
invoke_raw + extract_* | Main loop consumes only unified text / tool_calls / meta |
| Old/new tool format compatibility | tool_calls and function_call normalized to the same list shape |
| Request-side compatibility patches | temperature, 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.pyVisit 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.