OpenAI-Compatible Client Base (OpenAIBaseClient)¶
Audience: Engineers adding a new OpenAI-wire-compatible LLM provider, or
debugging why one leaked an OpenAI gpt-* default.
Related files:
packages/ai-parrot/src/parrot/clients/openai_base.py—OpenAIBaseClient(stays in core — see FEAT-523 note below)packages/ai-parrot-client-openai/src/parrot/clients/openai/client.py—OpenAIClient(OpenAI-only)packages/ai-parrot-client-{openrouter,moonshot,nvidia,local,groq,zai}/ src/parrot/clients/{openrouter,moonshot,nvidia,local,groq,zai}/client.py,packages/ai-parrot-client-amazon/src/parrot/clients/amazon/nova/mantle.py— the 8 wire subclasses (FEAT-523: each now ships from its ownai-parrot-client-<provider>satellite distribution — seedocs/migration/feat-523-llm-client-satellites.md)packages/ai-parrot/src/parrot/clients/base.py—AbstractClient(shared machinery)sdd/specs/openai-compatible-clients.spec.md— full design (FEAT-438)tests/clients/test_openai_base.py,test_openai_base_parity.py,test_openai_compatible_defaults.py,test_fallback_model_shadowing.pyexamples/clients/smoke/— live, credential-gated smoke scripts (this doc's companion — see "Live Smoke Testing" below)docs/clients/bedrock-mantle.md— a worked example of one subclass
Why This Exists¶
Before FEAT-438, every OpenAI-wire-compatible provider (OpenRouter, Moonshot,
Nvidia, LocalLLM/vLLM, Bedrock Mantle) subclassed OpenAIClient directly —
inheriting not just the shared wire protocol but also OpenAI-the-provider's
own defaults: _default_model, _fallback_model, _lightweight_model all
pointed at gpt-* ids. A non-OpenAI subclass that didn't override every one
of those attributes could silently call invoke() (or hit a capacity
fallback) against a gpt-* model id on a provider that has no such model —
the production incident that motivated this feature (a DeepSeek V3.2 request
on OpenRouter 404'ing against a gpt-4.1 fallback).
FEAT-438 fixes this by inserting a neutral layer — OpenAIBaseClient —
between AbstractClient and every wire-compatible client. It owns the wire
protocol (message shaping, the tool-calling loop, streaming, invoke(),
batch_ask(), …) but declares zero OpenAI-provider defaults.
Hierarchy¶
AbstractClient (base.py) [+ FEAT-438 G5 shadowing fix]
├── OpenAIBaseClient (openai_base.py) [NEW — wire protocol, no OpenAI defaults]
│ ├── OpenAIClient (ai-parrot-client-openai) [OpenAI-only: gpt-*, Responses API, Sora…]
│ ├── OpenRouterClient (ai-parrot-client-openrouter)
│ ├── MoonshotClient (ai-parrot-client-moonshot) [drops ask_stream/invoke bypass overrides]
│ ├── NvidiaClient (ai-parrot-client-nvidia)
│ ├── LocalLLMClient ── vLLMClient (ai-parrot-client-local / -vllm) [drops _is_responses_model override]
│ ├── BedrockMantleClient (ai-parrot-client-amazon) [drops fallback_model workaround]
│ ├── GroqClient (ai-parrot-client-groq, keeps native AsyncGroq SDK)
│ └── ZaiClient (ai-parrot-client-zai, keeps native zai SDK)
├── GrokClient (ai-parrot-client-grok) [UNCHANGED — xai_sdk, not OpenAI wire]
└── AnthropicClient / BedrockConverseClient / GoogleGenAIClient / … [each its own satellite — see FEAT-523 note below]
FEAT-523 (PEP 420 LLM Client Extraction): every class named above
except AbstractClient/OpenAIBaseClient themselves now ships from its
own ai-parrot-client-<provider> satellite distribution rather than
ai-parrot core — see "Adding a New OpenAI-Compatible Provider" below
for the current registration mechanism, and
docs/migration/feat-523-llm-client-satellites.md for the full picture.
OpenAIClient is the one privileged subclass: it's the only place gpt-*
model ids, OpenAIModel alias/deprecation normalization, the Responses API,
Sora video generation, and openai.RateLimitError-typed capacity detection
belong. Every other provider subclasses OpenAIBaseClient directly, never
OpenAIClient.
What Belongs Where¶
AbstractClient (base.py) — provider-agnostic¶
Shared across every client, OpenAI-wire or not: the tool-calling contract
(_prepare_tools/_resolve_tool_format/ToolFormat), _resolve_invoke_model
(already None-safe — the no-defaults decision rides on this), the shared
message-context builder (_prepare_conversation_context), structured-output
schema helpers (_build_response_format_from/_oai_normalize_schema/
_make_openai_strict_tool), the async-context-manager protocol
(__aenter__/__aexit__/_ensure_client()), and the per-loop SDK client
cache (self.client — a loop-local property; see
sdd/tasks/completed/TASK-795-abstract-client-per-loop-cache.md).
OpenAIBaseClient (openai_base.py) — the wire protocol, no defaults¶
Owns the OpenAI wire format — request/response shaping, streaming accumulation, tool-loop mechanics — with no model-id opinions:
tool_format = ToolFormat.OPENAI(the one opinion every wire-compatible provider genuinely shares: OpenAI-shaped tool schemas).- Declares no model attributes.
_default_model/_fallback_model/_lightweight_modelare left unset, so they inheritAbstractClient'sNone— meaninginvoke()'s resolution chain (explicitmodelkwarg →_lightweight_model→self.model) falls through toself.modelinstead of silently defaulting to agpt-*id. get_client()— builds anAsyncOpenAI-shaped client from the subclass'sbase_url/api_key/timeout. Provider subclasses override this only when they need a different SDK (Groq →AsyncGroq, Zai → the nativezaiSDK) or extra client kwargs (OpenRouter's custom headers)._chat_completion(self, model, messages, use_tools=False, stream=False, **kwargs)— the single funnel. Every wire call —ask(),ask_stream(),resume(),invoke()— routes through this one method. See "The Funnel Contract" below.- The extracted tool-calling loop (
_run_tool_call_loop) — one implementation shared byask()andresume(), replacing what used to be duplicated logic ingpt.py. - Neutral hooks a subclass may override:
_normalize_model()(identity in the base —OpenAIClientoverrides it forOpenAIModelalias handling),_is_responses_model()(alwaysFalsein the base — onlyOpenAIClienthas a Responses API to route to). _resolve_model(),_with_extra_body(),_encode_image_for_openai(),_upload_file(),batch_ask().
A provider subclass (e.g. OpenRouterClient, NvidiaClient, …)¶
Owns exactly three things:
- Endpoint + credential resolution in
__init__—base_url,api_key(with the provider's own env-var fallback chain), forwarded tosuper().__init__(api_key=..., base_url=..., **kwargs). - Real provider-specific behavior, kept as an override only when it
encodes something genuinely different about that provider's wire
contract — e.g. Nvidia's rate limiter + create-not-parse pattern,
Moonshot's thinking-param/K-series-model guards, OpenRouter's
extra_bodyprovider-routing injection, vLLM's guided-output kwargs. - Model-id defaults, if any — a subclass MAY set
_default_model/_fallback_model/_lightweight_modelto its own real model ids (e.g. Groq sets_lightweight_model = "kimi-k2-instruct"). It must never leave an OpenAI id in one of these attributes.
Overrides that existed only to dodge OpenAIClient's OpenAI-specific
behavior (LocalLLM's _is_responses_model override, Moonshot's
ask_stream/invoke bypass workarounds) were deleted during the
FEAT-438 rebase — once the base class carries no such behavior, there is
nothing left to dodge.
Phase-2 native-SDK subclasses (GroqClient, ZaiClient)¶
Groq and Zai speak an OpenAI-shaped API but through their own SDKs
(AsyncGroq, the official synchronous zai client) rather than AsyncOpenAI
directly. They still subclass OpenAIBaseClient — they override
get_client() to return their native SDK client, and override
_chat_completion() as the adaptation seam:
# groq.py — AsyncGroq mirrors AsyncOpenAI's chat.completions.create surface,
# so the funnel override is a straight pass-through.
async def _chat_completion(self, model, messages, use_tools=False, stream=False, **kwargs):
return await self.client.chat.completions.create(
model=model, messages=messages, stream=stream, **kwargs
)
# zai.py — the zai SDK is synchronous, so the funnel wraps it in a thread.
async def _chat_completion(self, model, messages, use_tools=False, stream=False, **kwargs):
request_args = {"model": model, "messages": messages, "stream": stream, **kwargs}
if stream:
return self._stream_completion(**request_args)
return await self._create_completion(**request_args) # internally asyncio.to_thread()
Their bespoke business logic that genuinely differs from the shared
implementation — Groq's explicit tool_format = ToolFormat.GROQ (Groq
rejects "strict": true, so it cannot inherit ToolFormat.OPENAI's
strict-schema behavior), Zai's "thinking" payload shape, both clients'
structured-output-vs-tools precedence rules — is kept, unchanged, gated
by dedicated payload-parity tests (tests/clients/test_openai_base_parity.py).
Per the spec: any parity divergence blocks that client's migration — never
silently normalize a payload to make it "fit" the shared path.
The Funnel Contract¶
_chat_completion(self, model: str, messages: Any, use_tools: bool = False,
stream: bool = False, **kwargs) -> Any is the only place a wire call
leaves the process. ask(), ask_stream(), resume(), and invoke() all
route through it (directly, or via the shared _run_tool_call_loop) instead
of each calling self.client.chat.completions.create(...) independently.
Why this matters: a subclass override of _chat_completion applies
universally — rate limiting, payload injection (OpenRouter's provider
extra_body), or SDK adaptation (Groq/Zai) is written once and every entry
point (ask, streaming, resuming a tool loop, invoke) picks it up
automatically. Before FEAT-438, gpt.py had near-duplicate wire-call sites
across these four entry points that could (and did) drift out of sync.
When adding a new override, always call super()._chat_completion(...) (or
otherwise preserve its model/messages/use_tools/stream/**kwargs
contract) unless you are fully replacing the wire call, as Groq/Zai do for
their native SDKs.
The No-gpt-*-Defaults Rule¶
OpenAIBaseClient and every direct subclass of it (i.e. everything except
OpenAIClient itself) MUST NOT set _default_model, _fallback_model, or
_lightweight_model to an OpenAI gpt-* id. Leaving them unset (None)
is always safe — the resolution chains in AbstractClient fall through to
self.model (the model the caller actually configured) rather than
defaulting to OpenAI.
This is enforced by tests/clients/test_openai_compatible_defaults.py:
test_openai_base_client_declares_no_model_defaults asserts the base has
none of the three attributes set on the class itself, and a parametrized
test_no_gpt_default_leak / test_invoke_chain_never_yields_gpt /
test_ask_payload_model_never_leaks_gpt sweep runs the same assertion across
every wire subclass (WIRE_SUBCLASSES in that file).
Adding a New OpenAI-Compatible Provider¶
FEAT-523 note: as of the PEP 420 LLM Client Extraction, a new provider
is a new satellite distribution, not a new file inside ai-parrot
core. The folder convention and entry-point registration below are
normative for every provider, OpenAI-wire-compatible or not — see
sdd/specs/pep-420-llm-clients.spec.md §2.
- Create the satellite's folder convention at
packages/ai-parrot-client-myprovider/src/parrot/clients/myprovider/:
parrot/clients/myprovider/
├── __init__.py # re-exports the client class(es) + model enum, __all__
├── client.py # subclasses OpenAIBaseClient (or AbstractClient directly)
└── models.py # MyProviderModel(str, Enum) + capability sets; pure data,
# must NOT import client.py
Subclass OpenAIBaseClient, not OpenAIClient, if the provider is
OpenAI-wire-compatible:
# packages/ai-parrot-client-myprovider/src/parrot/clients/myprovider/client.py
from ..openai_base import OpenAIBaseClient # 2 dots — client.py lives
# one level deeper than the
# old flat clients/<p>.py files
class MyProviderClient(OpenAIBaseClient):
client_type: str = "myprovider"
client_name: str = "myprovider"
# Every provider_keys entry this class answers to (primary first) and
# the model catalog enum it owns — required by LLMFactory discovery:
provider_keys: tuple[str, ...] = ("myprovider",)
models: type[MyProviderModel] = MyProviderModel
# Only set the model-default attributes below if MyProvider actually
# has a sensible one — never an OpenAI gpt-* id:
# _default_model: str = "myprovider/some-model"
# _lightweight_model: str = "myprovider/small-model"
def __init__(self, api_key: str | None = None, base_url: str | None = None, **kwargs):
resolved_key = api_key or config.get("MYPROVIDER_API_KEY")
super().__init__(
api_key=resolved_key,
base_url=base_url or "https://api.myprovider.com/v1",
**kwargs,
)
# AbstractClient.__init__ only assigns self.api_key from kwargs
# when 'api_key' is present — re-set explicitly if your class
# needs to guarantee it (see the pattern in nvidia/openrouter's
# own client.py).
self.api_key = resolved_key
- Register it via a
parrot.clientsentry point — declare it in the satellite's ownpyproject.toml, one line perprovider_keysentry (targetparrot.clients.myprovider:MyProviderClient):
LLMFactory._discover() reads
importlib.metadata.entry_points(group="parrot.clients") lazily, on
first use — there is no manual registry to edit in factory.py
anymore (the old SUPPORTED_CLIENTS dict literal / hand-written
_lazy_* closures were removed by FEAT-523). Also add ai-parrot as
a dependency ([tool.uv.sources] ai-parrot = { workspace = true } for
an in-repo satellite) and only the SDK your provider's own code
genuinely imports — grep first; don't assume openai is needed even
if you subclass OpenAIBaseClient, since that SDK is a base
ai-parrot dependency already.
-
Only override
get_client()if you need a non-AsyncOpenAISDK (native Groq/Zai pattern) or extra client kwargs (OpenRouter's custom headers pattern). -
Only override
_chat_completion()if you need to inject payload data on every call (OpenRouter'sextra_body) or adapt a native SDK (Groq/Zai). Always preserve or explicitly replace themodel/messages/use_tools/stream/**kwargscontract. -
Only re-add a business-logic override (
ask,ask_stream,invoke, tool preparation, structured-output shaping, …) when it encodes real, provider-specific behavior — not to dodge OpenAI-specific behavior, since the base class has none. If you do add one, write a payload-parity test first (seetest_openai_base_parity.py'sWIRE_SUBCLASSESroster) — parity divergence should be a deliberate, tested decision, never silent. -
Never set
_default_model/_fallback_model/_lightweight_modelto agpt-*id. Leave them unset unless you have a real model id for that provider. -
Add a smoke script in
examples/clients/smoke/(see below) and a doc page if the provider has enough provider-specific surface to warrant one (followdocs/clients/bedrock-mantle.md's structure).
Live Smoke Testing¶
examples/clients/smoke/ holds one credential-gated script per endpoint
(smoke_openai.py is the positive control against OpenAIClient itself;
the rest cover the Phase-1/Phase-2 subclasses). Each script:
- Skips cleanly (
SKIPPED (no <ENV_VAR>), exit 0) if its provider's credentials aren't set — safe on keyless machines and never wired into CI. - Otherwise constructs the client via
LLMFactory.create("provider:model")insideasync with client:(required —AbstractClientdoes not auto-enter its async context forask()/invoke(), onlycomplete()does) and runs three legs: plainask(),ask()with one@toolregistered, andinvoke()— the last being the original 404 repro path this feature exists to prevent. - Prints a compact
PASS/FAIL/SKIPPEDsummary per leg.
These are plain scripts, not pytest — run them manually:
Worktree note: if you run a smoke script directly (not via pytest)
from inside a .claude/worktrees/<feature>/ checkout, cd-ing there is not
enough — this repo's editable install .pth entries point at the main
checkout's packages/*/src, so a bare python script.py will silently
import the main checkout's code. Prepend the worktree's own src dirs via
PYTHONPATH, and make sure the compiled Cython extensions
(parrot/utils/types*.so, etc.) exist under the worktree (copy them from the
main checkout if missing) before trusting smoke results as evidence about
worktree code.