Fix: emit LlmResponse.error_code as a plain string from the OpenAI Responses model - #67
Open
AmaadMartin wants to merge 2 commits into
Open
Fix: emit LlmResponse.error_code as a plain string from the OpenAI Responses model#67AmaadMartin wants to merge 2 commits into
AmaadMartin wants to merge 2 commits into
Conversation
added 2 commits
July 30, 2026 18:42
_response_to_llm_response assigned the FinishReason enum member to LlmResponse.error_code after construction. LlmResponse does not enable pydantic's validate_assignment, so the str coercion that every other producer gets from the constructor never ran and the field kept the enum. Callers that log or persist the Optional[str] field saw "FinishReason.MAX_TOKENS" instead of "MAX_TOKENS", and a python-mode model_dump() leaked the enum object. Assign finish_reason.value so the field holds the bare code. Equality against FinishReason members still holds because it is a str-based enum.
str(x), f'{x}' and model_dump() on a field that is already exactly a str
cannot fail once `type(...) is str` and the value assertion pass. Also trim
the fix's comment to the one fact a reader cannot see from the types.
This was referenced Jul 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
No existing issue.
Problem:
LlmResponse.error_codeis declaredOptional[str](
src/google/adk/models/llm_response.py:93), but the OpenAI Responses modelstores a
google.genai.types.FinishReasonenum member in it._response_to_llm_responsesets the field by post-construction attributeassignment (
src/google/adk/labs/openai/_openai_responses_llm.py:731):LlmResponse.model_config(llm_response.py:52-56) sets onlyextra,alias_generatorandpopulate_by_name— it does not enable pydantic'svalidate_assignment. Pydantic v2 therefore runs no validation on assignment,so the
strcoercion that every other producer of this field gets for freefrom the constructor never runs, and the enum member survives in a
str-typedfield.
FinishReasonis astr-based enum that does not overrideEnum.__str__/Enum.__format__, so the leak is user-visible:That reaches users through both public entry points, since the same converter
backs non-streaming generation (
_openai_responses_llm.py:1079) and thestreaming finalizer (
_openai_responses_llm.py:991), andEventsubclassesLlmResponse(events/event.py:92). Consumers that interpolate or persist thevalue —
a2a/converters/event_converter.py:457,463(str(event.error_code)),plugins/logging_plugin.py:202,plugins/debug_logging_plugin.py:461and theStringsession column atsessions/schemas/v0.py:277— all see theFinishReason.MAX_TOKENSform.Solution: assign
finish_reason.valueinstead of the enum member, so thefield holds the bare code. This is the one-token change that puts this producer
on the same footing as every other one in the repo (
LlmResponse.create()atllm_response.py:218,231,utils/streaming_utils.py:387,407, and the siblingbranch at
_openai_responses_llm.py:877), all of which pass through theconstructor and are already coerced.
Alternatives considered and rejected:
validate_assignment=TrueonLlmResponse. It would fix this at theroot, but it silently changes coercion and validation behaviour for every
field on
LlmResponseand every subclass — including the publicEventsurface — which is far out of proportion to this defect.
src/google/adk/models/lite_llm.py(
:2126,:2896,:2917) in the same PR. Deliberately out of scope: thatis a separate provider with its own test suite, and it is handled by a
separate change so the two can be reviewed independently.
Intentional behaviour change (please read). This is a deliberate correction
on a public field, not an internal refactor.
str(error_code)/ f-string interpolation now yields'MAX_TOKENS'instead of'FinishReason.MAX_TOKENS'; python-modemodel_dump()yields astrinstead of the enum object;isinstance(error_code, types.FinishReason)becomesFalse;error_code.value/.namenow raiseAttributeError.FinishReasonmembers(
FinishReason.MAX_TOKENS == 'MAX_TOKENS'isTrue, so existing comparisonskeep working — the pre-existing assertions in the tests below prove it),
model_dump_json()(already serialised by value), and everything aboutfinish_reason, which stays atypes.FinishReason.string interpolation; all are unaffected or strictly improved.
Collision check. Ran
gh pr list --repo AmaadMartin/adk-python --state open --limit 100and diffedthe file lists of the plausibly adjacent PRs. The only related work is the
lite_llmequivalent (fix/litellm-error-code-plain-str) andfix/lite-llm-streaming-none-finish-reason-guard; both touch onlysrc/google/adk/models/lite_llm.pyandtests/unittests/models/test_litellm.py, so there is no overlap with thischange and no reason to stack. No open PR touches
_openai_responses_llm.py.Testing Plan
Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.
Unit Tests:
Added
test_error_code_is_plain_string_not_finish_reason_enum, parametrizedover both reachable outcomes of the
finish_reason and finish_reason != STOPbranch (
MAX_TOKENSfromstatus: 'incomplete',OTHERfromstatus: 'failed'), so a future edit to_map_finish_reasoncannot silentlyregress one arm. The type check is written
type(x) is strrather thanisinstance(x, str)on purpose:FinishReasonsubclassesstr, so anisinstancecheck passes against the buggy value and proves nothing.Also extended existing tests rather than cloning them:
test_streaming_incomplete_event_sets_max_tokens— proves the fix throughthe public
generate_content_async(..., stream=True)API, not just theprivate converter.
test_streaming_generation_failed_event_is_terminal— pins thealready-correct constructor path (
_openai_responses_llm.py:877) to the samestrcontract, so it cannot drift.test_response_parsing_maps_text_reasoning_tool_calls_and_usage— negativecase: a
completedresponse still leaveserror_code is None.No existing test or assertion was deleted, skipped, or weakened. In particular
the pre-existing
error_code == types.FinishReason.MAX_TOKENS(line 1286) anderror_code == types.FinishReason.OTHER(line 1303) assertions are keptuntouched and still pass — they are the guard that this change does not break
callers comparing against enum members.
Coverage. 100% line and branch coverage of the changed code. Measured with
--cov=google.adk.labs.openai._openai_responses_llm --cov-branch --cov-report=term-missing: the changed line and the enclosing branch at line727 are absent from the
Missingcolumn, with all three outcomes exercised(
MAX_TOKENS,OTHER, and theSTOP→error_code is Nonenegative arm).Mutation check — every new test was proven able to fail. Reverted the
source hunk to the unfixed
llm_response.error_code = finish_reasonand re-ranthe file: 3 failed, 50 passed. The failures are exactly the tests that pin the
new behaviour:
The two assertions that correctly keep passing under the mutation are the
constructor-path guard and the
error_code is Nonenegative case — neither isaffected by this defect, which is the expected result.
Manual End-to-End (E2E) Tests:
No credentials, network, or live model needed — the defect is deterministic
from a plain
Mappingpayload. To reproduce and confirm, run before and afterthe change:
Before:
<enum 'FinishReason'> FinishReason.MAX_TOKENS <FinishReason.MAX_TOKENS: 'MAX_TOKENS'>After:
<class 'str'> MAX_TOKENS 'MAX_TOKENS'Formatting/lint on the two touched files, matching the repo's pre-commit
configuration (
pyink25.12.0,isort,ruff0.15.17):No type-checker or linter suppressions were added, and no new dependencies or
imports were introduced.
Checklist
CI status on this PR
All three red jobs are pre-existing failures unrelated to this change, each
already the subject of a separate in-flight PR. I deliberately did not fix them
here: doing so would duplicate that work, and CI workflow changes are out of
scope for this fix.
1.
Unit Tests(3.10–3.14) — pre-existing, reproduced on pristinemain.The run is
1 failed, 9362 passed; the single failure is`tests/unittests/cli/utils/test_cli_tools_click.py::test_telemetry_cli_commands
(bare CLI group exit code on click >= 8.2). Every test intests/unittests/labs/openai/passed in CI. Verified locally that the failure is not mine by checking outmain` unmodified and running the same test:2.
Pre-commit Linter— pre-existing infrastructure failure. The job runspre-commit run --all-files, and theupdate-constraintshook(
pass_filenames: false) dies with./scripts/update_constraints.sh: line 103: uv: command not foundon the runner. It fails identically on other open PRs.It does not apply to my files: running pre-commit against just the two files in
this diff passes every applicable hook, and
update-constraintsis skippedbecause neither file matches its
files: ^(pyproject\.toml|constraints-.*\.txt)$pattern.
3.
Mypy Check(3.10–3.13) — false positive from the gate's comparisonstep. The job reports two "NEW" errors, both in
src/google/adk/models/lite_llm.py. That file is not in this diff(
git diff main --name-onlylists exactly two files, neither of themlite_llm.py), and it cannot be affected by this change: its entire ADK importclosure is
base_llm,llm_request,llm_responseandutils._google_client_headers, none of which importlabs.openai. The job'sown output shows the totals are identical on both sides:
Equal totals with two lines reported as "new" means the
comm -13comparison ismismatching duplicate lines, not detecting a regression — the step pipes mypy
through
sed 's/:\([0-9]\+\):/::/g', which collapses distinct errors in the samefile to identical strings, so
commbecomes sensitive to multiplicity ratherthan content. Re-running the job on the same commit reproduces it, so it is
deterministic rather than flaky. I also ran the gate's exact
baseline-vs-PR logic locally against the file this PR does change and got no new
errors:
Since CI could not be made green without stepping on other in-flight PRs, the
change was validated locally on the exact pushed commit (
77c127a):