Skip to content

Conversation

@dsfaccini
Copy link
Collaborator

@dsfaccini dsfaccini commented Nov 30, 2025

Closes #1996

This is an attempt at adding a timestamp field directly on ModelRequest. This PR also overrides the timestamp in ModelResponse that is set to the value returned by the provider (created or created_at in most cases).

Previously, only the response got a timestamp. The request's timestamp was set by the first part. The problem main with this approach is that some providers, like openai, return unix time (seconds passed since unix epoch), which for very fast requests can mean (as far as the user sees), that the response's timestamp is earlier that the request's.

Thus for absolute certainty that this is not the case, this PR sets both timestamps locally. The provider's timestamp is still stored in the models, in the response's provider_details['timestamp'].

Many tests needed adjusting because the timestamp field now becomes part of every snapshot. If this is undesired we could set the timestamp to be a private field in the model? I don't know exactly how that would go but there must be a way :).

Note: I see one commit from #3592 snuck into here, will be irrelevant when that merges.

dsfaccini and others added 10 commits November 28, 2025 12:42
Clarify that agents can be produced by a factory function if preferred.
The ModelRequest snapshots for Google/Vertex URL input tests were missing
the timestamp field that was added in the parent commit.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

_: KW_ONLY

timestamp: datetime = field(default_factory=_now_utc)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this should have a default, as it would be filled in for old requests without this field when they are deserialized and give the false impression that this date is accurate.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that makes sense

raw_finish_reason = candidate.finish_reason
if raw_finish_reason: # pragma: no branch
vendor_details = {'finish_reason': raw_finish_reason.value}
if response.create_time is not None:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be done separately from whether this a finish reason

_model_name=first_chunk.model_version or self._model_name,
_response=peekable_response,
_timestamp=first_chunk.create_time or _utils.now_utc(),
_timestamp=_utils.now_utc(),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be a default or something, so we don't need to repeat this?

def _process_response(self, response: chat.ChatCompletion) -> ModelResponse:
"""Process a non-streamed response, and prepare a message to return."""
timestamp = number_to_datetime(response.created)
timestamp = _utils.now_utc()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can drop this right, because the field already has a default?

_provider_name=self._provider.name,
_provider_url=self._provider.base_url,
# type of created_at is float but it's actually a Unix timestamp in seconds
_provider_timestamp=int(first_chunk.response.created_at),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why aren't we using number_to_datetime anymore?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we set _provider_timestamp which is an int, when we pass that into the provider_details we do the conversion to datetime

this cast makes it seem like we're losing precision here but in reality those are seconds, don't know why the streaming model has it as a float

will double-check in case I oversaw something but this is intentional

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"type of created_at is float but it's actually a Unix timestamp in seconds"

That depends on the API right? OpenAI may return a timestamp with 0 decimals, but maybe others actually send a float using the decimals for milliseconds.

# After the chunk with native_finish_reason 'completed', OpenRouter sends one more
# chunk with usage data (see cassette test_openrouter_stream_with_native_options.yaml)
# which has native_finish_reason: null. Since provider_details is replaced on each
# chunk, we need to carry forward the finish_reason from the previous chunk.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why was this not necessary previously?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just from following the logic it seems that: on the last chunk _map_provider_details was returning an empty dict, so it wasn't overriding the provider_details. Now the dict is by default not empty (because of the timestamp) so it overrides the finish_reason: 'completed' with None.

Makes me wonder now if the we should have separate provider_details['timestamp'] for the first and last chunks?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah please see if there's a way to clean up this logic. It may be better to always merge the new dict into the existing dict; that will have the same outcome of preserving the finish reason right?

if isinstance(part, TextContent):
return part.text
elif isinstance(part, ImageContent | AudioContent):
elif isinstance(part, (ImageContent, AudioContent)):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please restore this, unions ftw

Comment on lines +100 to +109
assert stream.provider_details == snapshot(
{
'timestamp': datetime.datetime(2025, 11, 2, 6, 14, 57, tzinfo=datetime.timezone.utc),
'finish_reason': 'completed',
'cost': 0.00333825,
'upstream_inference_cost': None,
'is_byok': False,
'downstream_provider': 'xAI',
}
)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

idk why this wasn't a problem before but from the snapshot alone it seems like this test hasn't been reran in a while, right?

- remove default timestamp on ModelResponse and set it manually
- adjust tests
Comment on lines +997 to +999
timestamp: datetime | None = None
"""The timestamp when the request was sent to the model."""

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ModelRequest.timestamp needs to be None by default for backwards compat

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That warrants a code comment!

@dsfaccini dsfaccini marked this pull request as ready for review December 3, 2025 16:13
docs/agents.md Outdated

!!! tip "Agents are designed for reuse, like FastAPI Apps"
Agents are intended to be instantiated once (frequently as module globals) and reused throughout your application, similar to a small [FastAPI][fastapi.FastAPI] app or an [APIRouter][fastapi.APIRouter].
Agents can be instantiated once as a module global and reused throughout your application, similar to a small [FastAPI][fastapi.FastAPI] app or an [APIRouter][fastapi.APIRouter], or be created dynamically by a factory function like `get_agent('agent-type')`, whichever you prefer.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this from this PR please ;)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will go away when I update the branch

parts.append(_messages.UserPromptPart(self.user_prompt))

next_message = _messages.ModelRequest(parts=parts)
next_message = _messages.ModelRequest(parts=parts, timestamp=now_utc())
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's se it just once on next_message below like we do with instructions, instead of adding it to 2 constructors

assert not self._did_stream, 'stream() should only be called once per node'

model_settings, model_request_parameters, message_history, run_context = await self._prepare_request(ctx)
self.request.timestamp = now_utc()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually if we have it here we don't need to set it above right?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah that makes sense, I wasn't totally sure about this line bc I ran into an issue with the temporal tests, but that issue was unrelated so this can stay. I'll remove the unnecessary assignments

instructions = await ctx.deps.get_instructions(run_context)
self._next_node = ModelRequestNode[DepsT, NodeRunEndT](
_messages.ModelRequest(parts=[], instructions=instructions)
_messages.ModelRequest(parts=[], instructions=instructions, timestamp=now_utc())
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above; if we set the timestamp already before sending model.request, we don't need it here right?

merged_message = _messages.ModelRequest(
parts=parts,
instructions=last_message.instructions or message.instructions,
parts=parts, instructions=last_message.instructions or message.instructions, timestamp=now_utc()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should definitely not create a new timestamp here, but use one of the existing ones

Comment on lines +997 to +999
timestamp: datetime | None = None
"""The timestamp when the request was sent to the model."""

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That warrants a code comment!

self.provider_details = {'finish_reason': raw_finish_reason.value}
provider_details_dict['finish_reason'] = raw_finish_reason.value
self.finish_reason = _FINISH_REASON_MAP.get(raw_finish_reason)
if self._provider_timestamp is not None:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're never setting this instance variable, so why have it at all?

return ModelResponse(
parts=items,
model_name=model_name,
timestamp=_utils.now_utc(),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is already the default value, right? So we shouldn't need to pass it. Same goes for the next file; please remove this wherever we do the same

_model_profile=self.profile,
_timestamp=number_to_datetime(first_chunk.created),
_provider_name=self._provider.name,
_provider_timestamp=first_chunk.created,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it was better to have number_to_datetime here

provider_details_dict['finish_reason'] = raw_finish_reason
self.finish_reason = _FINISH_REASON_MAP.get(raw_finish_reason)
if self._provider_timestamp is not None: # pragma: no branch
provider_details_dict['timestamp'] = number_to_datetime(self._provider_timestamp)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is in the loop and we never reset self._provider_timestamp, we'll be calling number_to_datetime on every chunk!

We only need to store it if it's not there already. And we can likely do that outside of the loop as well.

provider_details.update(_map_openrouter_provider_details(response))
return provider_details
if openrouter_details := _map_openrouter_provider_details(response):
provider_details = {**(provider_details or {}), **openrouter_details}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I fee l like this was easier when we always had a dict; was there a specific reason to add | None to these provider_details methods?

self.provider_details = {'finish_reason': raw_finish_reason}
provider_details['finish_reason'] = raw_finish_reason
self.finish_reason = _RESPONSES_FINISH_REASON_MAP.get(raw_finish_reason)
if self._provider_timestamp is not None: # pragma: no branch
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as elsewhere

# After the chunk with native_finish_reason 'completed', OpenRouter sends one more
# chunk with usage data (see cassette test_openrouter_stream_with_native_options.yaml)
# which has native_finish_reason: null. Since provider_details is replaced on each
# chunk, we need to carry forward the finish_reason from the previous chunk.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah please see if there's a way to clean up this logic. It may be better to always merge the new dict into the existing dict; that will have the same outcome of preserving the finish reason right?

parts=cast(
list[ModelResponsePart], split_content_into_text_and_thinking(response, self.profile.thinking_tags)
),
timestamp=_utils.now_utc(),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this the default already?

last_message.parts = [*last_message.parts, part]
else:
self.messages.append(ModelRequest(parts=[part]))
self.messages.append(ModelRequest(parts=[part], timestamp=_now_utc()))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is exactly the type of situation where we do NOT want to set the timestamp, as we don't know what the actual timestamp was when these messages were originally sent to the model. (since the message history may have model requests from earlier agent runs)

I suggest having a close look at where we set timestamp=_now_utc(), because some other cases (like with a2a or mcp) may have the same issue. If we promise we set it when it's sent to the model, I think we should only set it then, not earlier. So that means inside the ModelRequestNode where we have it now, and in pydantic_ai.direct.model_request.

dsfaccini and others added 3 commits December 11, 2025 07:48
Resolved import conflict in test_openai_responses.py - combined datetime
imports from our branch with Literal import from main.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Access ModelRequest-ModelResponse timestamps and duration as fields

2 participants