ADR-027: The model object¶
Status¶
Accepted.
Decision¶
D1. The pack says which methods this rule rewrites, and one it does not name is not a bail¶
params.methods lists the methods the scan recognises on a model;
params.rewrites, keyed by legacy method, says where each went (service,
streaming form, arguments, configuration keyword). A method in methods and
not in rewrites is written by no rule: its finding is claimed by nobody and
ADR-010 F-1 leaves the file as it was. No bail code means "not written yet",
because the vocabulary describes the user's code. A stream= that is not a
literal True or False is dynamic_stream_flag: the two call different
methods.
D2. The constructor is deleted and what it held is copied into every call¶
The constructor statement goes with the blank line above it; the model name is
folded into each call as model= and the configuration is rebuilt at each call
site. A copy must mean the same at the call: nothing in it may run (a call, an
await, a comprehension) and each name in it must have exactly one
assignment, the same one at both places; otherwise the group is
ctor_argument_not_portable. A shared configuration is not hoisted, because
that invents a name and a scope no pack parameter gives (ADR-006). A
constructor with no model name is default_model_name_required, because the
legacy default names a retired model (C-21).
D3. Positional arguments are mapped by index, and an explicit None is not given¶
Positional constructor arguments map through the pack's ctor_order, the
legacy order in which safety_settings precedes generation_config (ADR-010
F-7, C-22), and one past index zero warns positional_args_mapped_by_index. A
literal None counts as not given, because every legacy parameter defaults to
None and it is how a caller reaches a later positional. More positionals
than the order names, or one parameter given twice, is
positional_arg_ambiguous: already a TypeError, which a mapping would turn
into a silently wrong model.
D4. Two shapes go in, one class comes out, and the field order is the destinations'¶
The configuration is read from the legacy configuration class (by
legacy_config_symbol, which this rule claims, or through types by
legacy_config_aliases, which the import rule renames) or from a mapping
literal, written in the argument or held by a name. A key outside the fifteen
in generation_config_keys, and any other shape (a computed call, a splat, a
non-literal key), is generation_config_not_static, because the unvalidated
mapping is how seed reached the server (C-23). Fields are emitted as the
constructor parameters in legacy order, then the configuration's keys as written
(then the safety table, ADR-028 D6). A call's configuration merges over the
constructor's key by key, as the legacy SDK did.
A name is followed when it has one plain assignment (name = value: not a
second binding, a parameter, an import, an unpacking, a loop or with target,
or an augmented or annotated assignment) and every place it is read is the
generation_config or safety_settings keyword of a constructor or call this
rule rewrites. Then no other reader sees the value and nothing changes it between
the assignment and the call (a stored key, update, an alias and a print each
refuse), and the value is read as if it were written in the argument. A
function's own name goes with its group, so a second group that reads it refuses
both; a module's name stays where it is. A legacy configuration call a module's
name holds stays too and becomes the new SDK's class, since the legacy class has
no counterpart under that name once the import is renamed. What was copied must mean the same at each call as where it
was assigned, which D2's ctor_argument_not_portable decides. A module's name
that another module changes after importing it is not seen.
D5. The token count drops the configuration, and the one keyword that stops it¶
count_tokens drops the constructor's configuration and warns
count_tokens_config_dropped, because types.CountTokensConfig has no field
the legacy generation_config maps onto and sampling does not change a count
(ADR-010 F-3). A keyword in the method's semantic_kwargs
(system_instruction, which is counted) refuses the group
count_tokens_config_carries_semantics instead.
D6. A rule may ask the import manager for a module, and the manager decides the name¶
The rule that owns the import statements calls offer(module, prefer,
fallback) for every submodule its pack maps, whether or not it emits one; any
rule that needs a name calls require(module), which returns the bound name or
introduces the import, anchored to the legacy statement that caused it. So one
pack fact lives in one place. When require gives no name the caller raises
the manager's reason: alias_collision when no candidate name is free.
D7. A group whose uses are not all rewritable is refused whole¶
A binding group is atomic (ADR-006): a model also read through a method with no
rewrite refuses the whole group receiver_method_unmapped, and a reference to
it that no rewritten call replaces (such as a comparison with None) refuses it
model_object_escapes, because deleting the constructor would leave that use
reading a name that no longer exists. Declaring a method without a rewrite is
how a pack says "recognise this, do not touch it", so the code is permanent.
D8. The client is a property of the file, recorded by whichever rule introduces it¶
configure_to_client records on a RuleContext slot the client expression it
bound (client, genai_client, self.client), so no rule imports another.
When the client could not be introduced, the slot carries that rule's code and
this rule re-raises it, because one defect gets one name
(ADR-012).
D9. Nothing is recorded until nothing can refuse¶
Every refusal of a group is asked before any replacement is written into the
context, because recording as it went would leave a half-rewritten group (D7).
imports.require comes after every other refusal and before the first record:
it reserves a name, and earlier it would leave an unused import in a refused
file.
D10. The width a call is measured at is read off its source line¶
ADR-026 D7 measures the emitted line. This rule replaces an expression inside
the author's statement (for chunk in model.generate_content(...),
return model.count_tokens(p).total_tokens), so everything on the source line
that is neither the call nor the indent is counted as it stands; for a call the
author already split across lines that rest counts zero, since it wraps anyway.
D11. A comment on a deleted statement moves to the statement that now comes first¶
A comment above the constructor is the author's prose and has nowhere else to go, nor has one on its line or between its arguments that no rewritten call takes over (a name, a model, a configuration key moved into a call carries the comments written beside it, as ADR-026 D7 says). The next statement keeps its own blank lines and gains the comments alone, those above first and then the rest in the order written; at the end of a block they keep the blank lines above them too and go before the block's own footer. A line that shares its statement with another keeps its own comments. When the next statement is deleted too, what was handed to it goes on to the one after, ahead of its own, so a row of deleted constructors leaves every comment above the first statement that stays. A commented constructor is not refused, because a comment above a model constant is ordinary.
D12. C-56 is a refusal about the answer, not about the call, and a warning where it is only read¶
response.text raised ValueError with no parts and now returns None, so a
handler for it never runs. The group is response_shape_changed when an
attribute in response_attrs_now_none is read, on the call's result (awaited or
not), a name it was assigned to, or the name a for binds over either (a
stream's chunks), where a handler could catch response_legacy_error: a try or
an except* with a handler naming it, Exception or BaseException, alone or
in a tuple, or a bare except:, and a with that calls contextlib.suppress
(resolved to that name, so an import under another name counts) with any of them
or with a *, which cannot be read. A name is resolved in its own scope, through
annotated assignments and walruses as well as plain ones, so a variable of the
same name in another function is not this call's. Nothing says the exception was
not relied on, and a wrongly refused file costs a manual edit where a wrongly
kept handler is a silent change. Any other read is rewritten, and the call
carries response_text_may_be_none, since the value can be None: a handler in
a caller, builtins.ValueError, a suppress reached through an alias and an
unguarded .strip() are not seen. The schema requires both fields or neither.
D13. A fix-time bail's order is the rule's, and it is not on the scan's ladder¶
The merged ladder in impact/planner.py exists so two scan-time passes cannot
disagree about one finding; a transform rule raises one code per group and
compares none. It declares its own order where it raises them: the client, the
group's shape, the constructor, each call in closure order, the response reads,
then the copied expressions. The client is a rung above the group, and a group
nothing can rewrite makes its constructor's arguments moot.
Consequences¶
tests/fixtures/scan/basic/app.after.pyand the fourtests/fixtures/scan/encoding/answer keys are produced by rule, byte for byte.tests/unit/test_generative_model_calls.pyruns the rule against an invented SDK that spells every pack parameter differently.- The rule reads the names the import rule reserves and the client the client rule places, so it runs after both (ADR-031 D1).
- Open: D2's copies stand until a measurement shows how often one model is read
from many call sites;
positional_arg_ambiguousmay go if the benchmark never sees it.