> ## Documentation Index
> Fetch the complete documentation index at: https://codexceed.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# avatar.model_client

Model decision protocol: constrained, validated decisions (§6).

The model returns one of three actions, never arbitrary prose. The harness
validates every decision before acting; a malformed decision is a *recoverable*
error fed back to the model, never executed and never fatal.

`parse_decision` is the pure validation boundary (no network), so it — and the
fakes that stand in for a real client in tests — are trivially testable.

## Classes

### `AskUser`

Decision to ask the user a question (blocks in a non-interactive run) (§6).

```python theme={null}
AskUser(*, type: Literal['ask_user'] = 'ask_user', question: str) -> None
```

**Fields**

| Field      | Type                  | Required |
| ---------- | --------------------- | -------- |
| `type`     | `Literal['ask_user']` | no       |
| `question` | `str`                 | yes      |

### `DecisionParseError`

Malformed model output — recoverable; fed back to the model (§6), never fatal.

Carries `usage` when the client exhausts its in-client retries, so a lost turn is
still billed — the expensive failure mode is exactly the one that must not be
undercounted (PR-#31 review).

Args:
message: The parse-failure description fed back to the model.
usage: Tokens spent across the failed attempts, or `None` if unreported.

```python theme={null}
DecisionParseError(message: str = '', usage: 'DecisionUsage | None' = None) -> None
```

### `DecisionRetryNote`

One malformed in-client attempt: what was wrong, and a capped raw excerpt.

```python theme={null}
DecisionRetryNote(*, error: str, raw: str = '') -> None
```

**Fields**

| Field   | Type  | Required |
| ------- | ----- | -------- |
| `error` | `str` | yes      |
| `raw`   | `str` | no       |

### `DecisionUsage`

Provider-reported token usage for one decision (all in-client attempts summed).

```python theme={null}
DecisionUsage(*, prompt_tokens: int = 0, completion_tokens: int = 0) -> None
```

**Fields**

| Field               | Type  | Required |
| ------------------- | ----- | -------- |
| `prompt_tokens`     | `int` | no       |
| `completion_tokens` | `int` | no       |

### `EmptyResponseError`

The provider returned an empty / whitespace-only / all-NUL body (a 200 with no content).

The OpenAI SDK does not retry this (it is a *successful* HTTP response), so it must be caught
explicitly and treated as a transport failure — not routed into the model parse-retry, which
would re-prompt the model for what is really a dead/stalled provider reply (ADR-0028 R2).

```python theme={null}
EmptyResponseError(message: str = '', usage: 'DecisionUsage | None' = None) -> None
```

### `FinalAnswer`

Decision claiming the task is complete — a proposal for the verifier (§6, §12).

```python theme={null}
FinalAnswer(*, type: Literal['final_answer'] = 'final_answer', answer: str) -> None
```

**Fields**

| Field    | Type                      | Required |
| -------- | ------------------------- | -------- |
| `type`   | `Literal['final_answer']` | no       |
| `answer` | `str`                     | yes      |

### `ModelClient`

Anything that turns a context packet into a validated decision (§6).

The real implementation calls an OpenAI-compatible endpoint and runs the
result through `parse_decision`; tests substitute a scripted fake.

```python theme={null}
ModelClient()
```

#### `ModelClient.adecide(self, context: ContextPacket) -> ModelDecision`

Async entry point for one decision (ADR-0029 R5); defaults to offloading sync `decide`.

`OpenAIModelClient` overrides this with a cancellable streaming path; every fake inherits
this bridge unchanged — its sync `decide` runs in a worker thread (uncancellable mid-call,
but fast and deterministic, so the runner's cancel-race still resolves promptly).

Args:
context: The assembled context packet.

Returns:
The validated decision for the current turn.

#### `ModelClient.decide(self, context: ContextPacket) -> ModelDecision`

Turn a context packet into a validated decision for the current turn.

Args:
context: The assembled context packet.

Returns:
The validated decision for the current turn.

### `ModelDecision`

One validated model decision: a thought plus exactly one action (§6).

`retry_trace` is a **harness-owned diagnostics channel**: the model client annotates
the decision with any malformed attempts it recovered from in-client, so the runner
can record them as evidence and journal them (invariant #5). It is never accepted
from raw model output — `parse_decision` clears it.

```python theme={null}
ModelDecision(*, thought_summary: str = '', action: ToolCall | FinalAnswer | AskUser, retry_trace: list[DecisionRetryNote] = <factory>, transport_trace: list[str] = <factory>, streaming_fallback: str = '', usage: DecisionUsage | None = None, transport: str = '') -> None
```

**Fields**

| Field                | Type                      | Required    |           |     |
| -------------------- | ------------------------- | ----------- | --------- | --- |
| `thought_summary`    | `str`                     | no          |           |     |
| `action`             | \`ToolCall                | FinalAnswer | AskUser\` | yes |
| `retry_trace`        | `list[DecisionRetryNote]` | no          |           |     |
| `transport_trace`    | `list[str]`               | no          |           |     |
| `streaming_fallback` | `str`                     | no          |           |     |
| `usage`              | \`DecisionUsage           | None\`      | no        |     |
| `transport`          | `str`                     | no          |           |     |

### `OpenAIModelClient`

Calls an OpenAI-compatible endpoint and validates the reply (§6, §18).

A malformed reply is fed back to the model for a bounded number of retries
before surfacing as a `DecisionParseError` (which the runner treats as a
recoverable, model-correctable error).

Args:
config: The harness configuration.
client: An injected OpenAI-compatible client, or `None` to build one lazily on
first use — so construction needs no credentials; the optional `openai`
extra and an API key are required only when `decide()` is first called.
max\_parse\_retries: Number of retries on malformed model output.
transport\_max\_retries: Transport-layer retries on a NUL/empty body or a request
failure (ADR-0028 R3); `None` takes `config.transport_max_retries`.
sleep: Backoff sleeper, injectable so tests exercise retries without real delay.
aclient: An injected async OpenAI-compatible client (the ADR-0029 R5 streaming path),
or `None` to build an `AsyncOpenAI` lazily on first `adecide()`.
asleep: Async backoff sleeper, injectable so async tests skip real delay.

```python theme={null}
OpenAIModelClient(config: HarnessConfig, client: Any = None, max_parse_retries: int = 2, *, transport_max_retries: int | None = None, sleep: Callable[[float], None] = <built-in function sleep>, aclient: Any = None, asleep: Callable[[float], Awaitable[None]] = <function sleep>) -> None
```

#### `OpenAIModelClient.adecide(self, context: ContextPacket) -> ModelDecision`

Call the endpoint asynchronously, streaming by default for idle-timeout + cancellation.

Streams native tool-calls when enabled (ADR-0029 R5) so a stall is caught at the idle
timeout regardless of generation length; a provider that can't stream trips
`_streaming_unsupported` and the SAME request is re-issued non-streaming for the rest of the
session (D4). Either path is cancellable mid-call (the runner races a cancel against it) and
raises `TransportError`/`DecisionParseError` exactly like the sync `decide`.

Args:
context: The assembled context packet.

Returns:
The validated decision for the current turn.

#### `OpenAIModelClient.decide(self, context: ContextPacket) -> ModelDecision`

Call the endpoint and validate the reply, retrying on malformed output (§6).

The default transport is native provider tool-calling (ADR-0003 A) — the
provider owns the call envelope, so a large patch can't die in hand-escaping;
`config.native_tool_calls=False` restores the legacy single-JSON-object protocol.
Either path raises `DecisionParseError` when every attempt is malformed.

Args:
context: The assembled context packet.

Returns:
The validated decision for the current turn.

### `StreamingUnsupportedError`

The provider can't stream tool-calls — trip the per-instance flag and fall back (ADR-0029 D4).

Deliberately NOT a `TransportError`: re-issuing the same streaming request would fail
identically, so it must not flow into the transport-retry. The client catches it once, flips
`_streaming_unsupported`, and re-issues the SAME request non-streaming for the rest of the
session. A capability verdict (a streaming-rejection 4xx, or unusable tool-call framing), never
a transient fault — when in doubt the discrimination defaults to `TransportError` (ADR-0029 D).

### `ToolCall`

Decision to invoke a named tool with validated input (§6).

```python theme={null}
ToolCall(*, type: Literal['tool_call'] = 'tool_call', name: str, input: dict = <factory>) -> None
```

**Fields**

| Field   | Type                   | Required |
| ------- | ---------------------- | -------- |
| `type`  | `Literal['tool_call']` | no       |
| `name`  | `str`                  | yes      |
| `input` | `dict`                 | no       |

### `TransportError`

A model call failed at the *transport* layer — NOT model-correctable (§16, ADR-0028).

A request timeout, connection reset, or an empty/NUL body (`EmptyResponseError`) means the
provider returned nothing usable. Unlike `DecisionParseError`, this is **never** fed back to
the model: it is retried in-client at the transport layer (re-issue the same request with
backoff), and on exhaustion surfaced to the runner as a system failure. Carries `usage` so
the billed-but-lost attempts are not undercounted.

Args:
message: A short description of the transport failure.
usage: Tokens spent across the failed attempts, or `None` if unreported.

```python theme={null}
TransportError(message: str = '', usage: 'DecisionUsage | None' = None) -> None
```

## Functions

### `build_messages(context: ContextPacket, *, native_tools: bool = False) -> list[dict[str, str]]`

Assemble the system + user messages for one decision (§9 packet → prompt).

Args:
context: The assembled context packet.
native\_tools: `True` for the native tool-calling transport (ADR-0003 A) — the
provider carries the tool schemas, so the prompt drops the JSON-envelope
contract and the prose tool list; `False` keeps the legacy protocol verbatim.

Returns:
The system + user messages for one decision.

### `build_tool_schemas(context: ContextPacket) -> list[dict]`

The function schemas for one decision: the advertised tools + the decision actions.

Each phase-admitted tool rides its real pydantic `input_schema`; `final_answer` and
`ask_user` become functions too, so every §6 decision shape is a structured call the
provider validates — never a hand-escaped JSON envelope (ADR-0003 A).

Args:
context: The assembled context packet (its `allowed_tools` are advertised).

Returns:
OpenAI-style `tools=` entries.

### `parse_decision(raw: str) -> ModelDecision`

Validate raw model output into a `ModelDecision`, or raise a recoverable error.

Args:
raw: The raw model output to validate.

Returns:
The validated `ModelDecision`.

Raises:
DecisionParseError: If `raw` is not valid JSON or not a valid decision.
