You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(models): stamp every appended message with an append-time timestamp on both surfaces
Route all content-bearing message appends through _ChatStateMixin._append_message
so the inert `timestamp` key is set uniformly. Previously only the async providers,
async tool loop, and the sync ConversationManager set it, leaving sync provider
assistant messages and the non-streamed tool-result path unstamped.
- Switch the sync providers (anthropic, ollama, openai_compat, llamacpp, hf.text)
assistant-response appends to _append_message.
- Switch both tool-loop engines' tool-result appends (streaming and non-streamed
_dispatch) to _append_message.
- Docs: add a "Message Timestamps" section, note _append_message on the mixin,
and correct the timestamp attribution in the provenance section; update CHANGELOG.
- Tests: cover the tool-loop dispatch stamping; give hand-rolled _chat/_Client fakes
the _append_message seam and ignore the inert key in exact-content asserts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: CHANGELOG.md
+1Lines changed: 1 addition & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -6,6 +6,7 @@
6
6
7
7
- **New** **Model strings carry an endpoint and capabilities inline** (`aimu.models.model_client.resolve_model`, mirrored on the async path). The text model string grammar is now `provider:model_id[@base_url][;flags]`. Appending `@<base_url>` overrides the endpoint for the OpenAI-compatible local-server providers (`llamaserver`, `lmstudio`, `vllm`, `hf-openai`, `sglang`, `ollama-openai`), so a single string can target a remote llama.cpp / vLLM server. A new generic `openai-compat:<model_id>@<base_url>` prefix reaches any OpenAI-compatible server not tied to a known provider (the `@<base_url>` is required there). A model id **not** in the provider catalog is allowed for these providers when its capabilities are declared with `;<flags>` (comma-separated from `tools,thinking,vision,audio,structured`); such ids resolve to a new `AdHocModel` (exported from `aimu.models`) instead of raising. Known ids keep their catalog spec and reject `;flags`. Cloud providers (`openai`, `gemini`) and non-OpenAI-compat providers (`anthropic`, `ollama`, `hf`, `llamacpp`) reject `@<base_url>` with an actionable error. No authentication is added; `api_key` stays unset. Tests: `tests/test_model_string.py`, `tests/test_adhoc_model.py`, `tests/test_resolve_model.py`, `tests/test_model_client_base_url.py`, `tests/test_aio_model_client_base_url.py`.
8
8
-**New****`ModelConnectionError` when an inference server is unreachable** (`aimu.models.base`, exported from `aimu.models` and `aimu.aio`). The OpenAI-compatible clients (`aimu.models.providers.openai_compat`, `aimu.aio.providers.openai_compat`; sync + async, streaming + non-streaming) now catch the OpenAI SDK's `APIConnectionError` at the `chat.completions.create` call (and during stream consumption, where a mid-stream drop can surface it) and re-raise it as `ModelConnectionError`**from** the original error, so the specific transport cause (e.g. `httpx.ConnectError: [Errno 61] Connection refused`) is preserved on the exception chain. This mirrors the existing `MCPConnectionError` / `A2AConnectionError` wrappers and lets a front end distinguish "server is down" from a generic failure instead of receiving a raw, provider-specific exception. Only `APIConnectionError` is wrapped; genuine HTTP/API errors still propagate with their own detail. Tests: `tests/test_openai_compat_connection_error.py`.
9
+
- **New** **Messages are timestamped at append time** (`aimu.models._internal.chat_state._ChatStateMixin._append_message`). Every message appended to `self.messages` now carries an inert `timestamp` (ISO-8601, append time) via a single `_append_message` seam that **every** content-bearing append routes through, on **both** surfaces: the shared mixin's user-turn / system-seed / tool-call-record paths, each concrete provider's assistant-response append (sync `aimu.models.providers.{anthropic,ollama,openai_compat,llamacpp,hf.text}`, async `aimu.aio.providers.{anthropic,ollama,openai_compat}`), and the tool-loop engines' tool-result appends (`aimu.agents._tool_loop`, `aimu.aio._tool_loop`, streaming and non-streaming). The user message is stamped at request time and the assistant/tool messages when they arrive, so a consumer gets accurate per-message times without its own bookkeeping. `timestamp` was already in `INERT_MESSAGE_KEYS`, so it is still stripped from every provider request; stamping never changes the payload sent to a model. Previously only the sync `ConversationManager` set the key; the client now fills it on every path (sync and async), and `ConversationManager.update_conversation` `setdefault`s it so a client-stamped value wins. `_append_message` uses `setdefault`, so a restore replay's existing timestamps are preserved. Tests: `tests/test_message_timestamps.py`.
9
10
- **New** **Gemma 4 model catalog** for the OpenAI-compatible providers (`aimu.models.providers.openai_compat`). Added the full suite (`GEMMA_4_E4B`, `GEMMA_4_12B`, `GEMMA_4_26B`, `GEMMA_4_31B`) to every local-server enum (`OllamaOpenAIModel`, `LMStudioOpenAIModel`, `VLLMOpenAIModel`, `HFOpenAIModel`, `LlamaServerOpenAIModel`, `SGLangOpenAIModel`), replacing the lone `GEMMA_4_12B` entry each previously carried. Capabilities are set from Google's Gemma 4 model card: `tools=True, thinking=True, vision=True` on all four (thinking surfaces over OpenAI-compat via `<think>`-tag parsing). Provider-appropriate ids include the MoE `google/gemma-4-26B-A4B-it` and the dense `google/gemma-4-31B-it` for the HuggingFace-repo servers. `vision=True` was also backfilled onto the existing Gemma 3/4 entries. **Audio is deliberately left off** (only E4B/12B are natively audio-capable, and audio input isn't reliably exposed by these local servers); each enum carries an inline comment recording the transport-specific reason. The async providers inherit these enums, so `aimu.aio` picks up the new members automatically.
Copy file name to clipboardExpand all lines: CLAUDE.md
+6-2Lines changed: 6 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -230,7 +230,7 @@ async def main():
230
230
231
231
**Shared infrastructure** (used by both sync and async surfaces, no duplication):
232
232
233
-
-**[aimu/models/_internal/chat_state.py](aimu/models/_internal/chat_state.py)**: `_ChatStateMixin` provides the I/O-free helpers shared by both bases: `system_message` lifecycle (always-live setter that swaps the in-history system entry), `reset()`, `_append_user_turn()`, `_collect_python_tool_specs()`, the capability properties (`is_thinking_model`, etc.), tool-call recording (`_prepare_tool_calls` / `_append_assistant_tool_calls` / `_record_tool_calls`), and structured-request resolution (`_structured_request`). Both `BaseModelClient` and `AsyncBaseModelClient` inherit it. None of this is duplicated across the two bases.
233
+
-**[aimu/models/_internal/chat_state.py](aimu/models/_internal/chat_state.py)**: `_ChatStateMixin` provides the I/O-free helpers shared by both bases: `system_message` lifecycle (always-live setter that swaps the in-history system entry), `reset()`, `_append_message()` (the single append seam that stamps every message with an inert append-time `timestamp`; see "Message Timestamps"), `_append_user_turn()`, `_collect_python_tool_specs()`, the capability properties (`is_thinking_model`, etc.), tool-call recording (`_prepare_tool_calls` / `_append_assistant_tool_calls` / `_record_tool_calls`), and structured-request resolution (`_structured_request`). Both `BaseModelClient` and `AsyncBaseModelClient` inherit it. None of this is duplicated across the two bases.
-**[aimu/models/_internal/json.py](aimu/models/_internal/json.py)**: `parse_json_response(text, schema=None)`, `generate_json(client, prompt, schema=None, *, retries=2, generate_kwargs=None)`, `extract_tool_calls(messages)`. Utilities for getting structured data out of model responses. Exported from `aimu.models` and top-level `aimu`.
236
236
-**[aimu/tools/mcp_format.py](aimu/tools/mcp_format.py)**: `mcp_tools_to_openai(tool_specs)` (spec conversion) and `mcp_content_to_text(tool_response)` (flatten a `call_tool` result to a string), shared by sync `MCPClient` and async `aio.MCPClient` (`get_tools()` / `as_tools()`).
@@ -740,6 +740,10 @@ When adding new models to a client:
740
740
- Message history persists across `chat()` calls on the same client instance.
741
741
- Use `ConversationManager` to persist conversations across sessions.
742
742
743
+
### Message Timestamps
744
+
745
+
Every message appended to `self.messages` carries an inert `timestamp` (ISO-8601, stamped at append time). The single append seam is `_ChatStateMixin._append_message(message)` ([aimu/models/_internal/chat_state.py](aimu/models/_internal/chat_state.py)): it `setdefault`s `timestamp` (so a restore replay's existing stamps are preserved), then appends. **Every** content-bearing append routes through it, on both surfaces: the shared mixin's user-turn / system-seed / tool-call-record paths, each concrete provider's assistant-response append (sync in `aimu/models/providers/*`, async in `aimu/aio/providers/*`), and the tool-loop engines' tool-result appends (`aimu/agents/_tool_loop.py`, `aimu/aio/_tool_loop.py`, streaming and non-streaming). The user turn is stamped at request time and the assistant/tool turns when they arrive, so a consumer reading `client.messages` (a UI, `ConversationManager`, a `SessionStore`) gets accurate per-message times without its own bookkeeping. `timestamp` is in `INERT_MESSAGE_KEYS`, so `strip_inert_keys()` removes it before every provider request; stamping never changes the payload sent to a model. `ConversationManager.update_conversation` `setdefault`s the key too, so a client-stamped value wins over its own persist-time stamp. In-process providers (HuggingFace, LlamaCpp) route their sync appends through the same seam; their async wrappers delegate to the wrapped sync client.
746
+
743
747
### Thinking Models
744
748
745
749
Models with `supports_thinking=True` support extended reasoning:
@@ -750,7 +754,7 @@ Models with `supports_thinking=True` support extended reasoning:
750
754
751
755
### Message Provenance (agent-loop vs user input)
752
756
753
-
Framework-injected turns are marked with a non-standard `"provenance"` key so a replayed or persisted transcript can distinguish them from genuine user input (both are `{"role": "user", ...}` and were otherwise byte-for-byte identical). The `Agent` loop tags its `final_answer_prompt` turn `PROVENANCE_FINAL_ANSWER`; the personal-assistant example tags scheduler pushes `PROVENANCE_PROACTIVE`. (`PROVENANCE_CONTINUATION` is **legacy / no longer produced** — the agent used to inject a "continue" user turn between tool rounds but now continues via `chat()` with no user message, so nothing is injected; the constant is kept only so older persisted transcripts still parse.) Genuine user input and ordinary assistant turns are **left untagged** (absence = ordinary), so display logic is just `message.get(PROVENANCE_KEY)`. The constants and the key live in [aimu/models/_internal/message_meta.py](aimu/models/_internal/message_meta.py), re-exported from `aimu.models` and top-level `aimu` (`PROVENANCE_KEY`, `PROVENANCE_CONTINUATION`, `PROVENANCE_FINAL_ANSWER`, `PROVENANCE_PROACTIVE`). Tagging is done by index after the injecting `chat()` turn completes (the shared `_AgentLoopMixin._tag_injected_turn`, sync + async), mirroring how providers attach the `"thinking"` key by index, so the public `chat()` signature is untouched. `"provenance"` is in `INERT_MESSAGE_KEYS`, so `strip_inert_keys()` removes it (alongside `"thinking"` and the `ConversationManager` `"timestamp"`) from every OpenAI-compat and Ollama request; Anthropic/HuggingFace drop it by rebuilding the payload. The Streamlit examples hide/mute tagged turns when re-rendering history. Streaming needs no marker: the injected prompts are *input*, never emitted as chunks, so the problem is purely at the replayed-history level. Tests: `tests/test_provenance.py` (+ async mirror in `tests/test_aio_agents.py`).
757
+
Framework-injected turns are marked with a non-standard `"provenance"` key so a replayed or persisted transcript can distinguish them from genuine user input (both are `{"role": "user", ...}` and were otherwise byte-for-byte identical). The `Agent` loop tags its `final_answer_prompt` turn `PROVENANCE_FINAL_ANSWER`; the personal-assistant example tags scheduler pushes `PROVENANCE_PROACTIVE`. (`PROVENANCE_CONTINUATION` is **legacy / no longer produced** — the agent used to inject a "continue" user turn between tool rounds but now continues via `chat()` with no user message, so nothing is injected; the constant is kept only so older persisted transcripts still parse.) Genuine user input and ordinary assistant turns are **left untagged** (absence = ordinary), so display logic is just `message.get(PROVENANCE_KEY)`. The constants and the key live in [aimu/models/_internal/message_meta.py](aimu/models/_internal/message_meta.py), re-exported from `aimu.models` and top-level `aimu` (`PROVENANCE_KEY`, `PROVENANCE_CONTINUATION`, `PROVENANCE_FINAL_ANSWER`, `PROVENANCE_PROACTIVE`). Tagging is done by index after the injecting `chat()` turn completes (the shared `_AgentLoopMixin._tag_injected_turn`, sync + async), mirroring how providers attach the `"thinking"` key by index, so the public `chat()` signature is untouched. `"provenance"` is in `INERT_MESSAGE_KEYS`, so `strip_inert_keys()` removes it (alongside `"thinking"` and the append-time `"timestamp"`) from every OpenAI-compat and Ollama request; Anthropic/HuggingFace drop it by rebuilding the payload. The Streamlit examples hide/mute tagged turns when re-rendering history. Streaming needs no marker: the injected prompts are *input*, never emitted as chunks, so the problem is purely at the replayed-history level. Tests: `tests/test_provenance.py` (+ async mirror in `tests/test_aio_agents.py`).
754
758
755
759
`supports_thinking` is the universal flag; *how* reasoning is requested is provider-specific and handled inside each client (Anthropic `thinking` param, HuggingFace `enable_thinking` template kwarg, `reasoning_effort` for OpenAI o-series). For OpenAI-compat and llama-cpp, reasoning arrives one of two ways and both are handled: a separate `reasoning_content` field on the delta/message (llama-server with the default `--reasoning-format deepseek`/`auto`, vLLM/SGLang reasoning parsers, which strip the tags server-side), **or** inline `<think>...</think>` tags in `content` (servers configured to pass tags through, e.g. `--reasoning-format none`). When `reasoning_content` is present it takes precedence and is not gated on `supports_thinking` (if the server sent it, it is reasoning); otherwise the `<think>` parser runs (`_ThinkingParser` streaming, `_split_thinking` non-streaming). Native Ollama uses its own `thinking` message field. The Anthropic client distinguishes two request shapes via a `ThinkingStyle` enum carried on each `AnthropicModel` member (analogous to HuggingFace's `ToolCallFormat`):
756
760
-**`ThinkingStyle.ENABLED`**: `thinking={"type": "enabled", "budget_tokens": N}`; the model always thinks up to the budget. Opus 4.6, Sonnet 4.6, Haiku 4.5.
0 commit comments