fix(stella-model): land the area:model audit batch (#566)#598
Merged
Conversation
A stray double blank line in accounted_call.rs after the use block was tripping `cargo fmt --check`, turning the CI gate red on main and on every branch cut from it. Reformat so downstream PRs inherit a green baseline.
Ten independent findings from the 2026-07 audit, each with a witness test
that fails on the old code:
- anthropic: to_anthropic_messages kept only the LAST system turn, so a
conversation carrying a skill preamble plus a policy block lost one of
them on Anthropic alone, silently. Accumulate and join("\n\n") like
to_openai_input / to_gemini_request_parts already do.
- anthropic/openai/zai: resolve pricing with resolve_for(<provider>, slug)
instead of the unscoped Catalog::resolve, whose documented "first row
wins" could cost a turn at another provider's list price once
`stella models refresh` merges the models.dev master list. Matches
gemini/vertex/bedrock, and zai's own with_identity re-resolve.
- openai: complete hard-coded finish_reason: None. Map ToolCalls / Stop
like every sibling; Length stays unreachable because response.incomplete
already errors rather than returning a truncated Ok.
- attachment: resolve_one decoded an inline base64 payload and then
immediately re-encoded the same bytes for every binary kind. Keep the
decode as validation, forward the caller's string verbatim.
- bedrock: sigv4::sign sliced &input.amz_date[..8] on a caller-supplied
pub(crate) field — a panic on a short or non-char-boundary value. Use
get(..8).unwrap_or(..). Well-formed dates are byte-identical, as the
botocore golden vectors confirm.
- credential: CredentialsFile derived Debug over a BTreeMap of plaintext
provider keys, so any {:?} dumped every secret in the file. Hand-write a
redacting impl and drop Debug from CredentialsFileData.
- openai: prompt_cache_key was pid+nanos only, the construction zai.rs
documents as insufficient. Add the same process-wide AtomicU64 suffix so
fleet siblings minted in one nanosecond do not share a cache shard.
- provider_listing: build paginated URLs with Url::query_pairs_mut so a
provider-controlled cursor containing & or # is percent-encoded instead
of truncating the query and injecting a parameter.
- provider_listing: exhausting MAX_PAGES was indistinguishable from a
clean finish — both returned the pages gathered so far as a success, so
a provider serving more pages silently produced a truncated catalog.
Return an Err naming the cap.
- zai: s/behaviour/behavior/ at the two sites the issue names.
Eight of the eighteen items are deferred, unchanged, with reasons in the
PR description: they need a new dependency, a new public error surface, a
public-API rename, or a product decision the audit explicitly leaves to
the owner.
Closes #566
Signed-off-by: macanderson <mac@oxagen.sh>
There was a problem hiding this comment.
Sorry @macanderson, you have reached your weekly rate limit of 500000 diff characters.
Please try again later or upgrade to continue using Sourcery
Contributor
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
macanderson
marked this pull request as ready for review
July 25, 2026 19:50
There was a problem hiding this comment.
Sorry @macanderson, you have reached your weekly rate limit of 500000 diff characters.
Please try again later or upgrade to continue using Sourcery
This was referenced Jul 25, 2026
Open
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.
Closes #566
Batched cleanup for
area:model— ten of the issue's eighteen items, each landed with a witness test verified to fail on the old code (git stash-style: production line reverted in place, test run, restored).What changed
Behavior changes (each with a witness)
anthropic.rs— every system turn survives the hoist.to_anthropic_messagesdidsystem = Some(message.content.clone()), so a conversation carrying more than one system turn (a skill preamble plus an injected policy block) silently lost every earlier one — on Anthropic alone, with no error and no log line. Now accumulates into aVec<String>and joins with a blank line, exactly asto_openai_inputandto_gemini_request_partsalready do.Prompt-cache note: for any caller that sends more than one system turn, the cached system prefix bytes change once, on the first turn after this lands. That is the price of not dropping instructions.
Witness:
every_system_turn_survives_the_hoist_joined_by_a_blank_line(plusno_system_turn_yields_no_system_prompt, pinning that "no system turn" still means no field on the wire).anthropic.rs/openai.rs/zai.rs— pricing is resolved scoped to the provider. All three constructors used the unscopedCatalog::resolve, whose documented semantics are "when the same slug exists on several providers, the first row wins". Harmless against the seed, but afterstella models refreshmerges the models.dev master list the same slug legitimately appears under several providers, and an Anthropic turn could be costed at OpenRouter's list price. Nowresolve_for("anthropic"|"openai"|"zai", &model), matchinggemini/vertex/bedrockand zai's ownwith_identityre-resolve.Witnesses:
pricing_is_scoped_to_anthropic_and_never_adopts_another_providers_row,pricing_is_scoped_to_openai_and_never_adopts_another_providers_row,new_resolves_pricing_scoped_to_the_zai_identity.openai.rs—finish_reasonis mapped, not hard-codedNone.Some(FinishReason::ToolCalls)when the turn produced tool calls,Some(FinishReason::Stop)otherwise, mirroringzai.rs.Lengthstays unreachable:response.incompletealready aborts the turn with a typed error rather than returning a truncatedOk(pinned by the existingcomplete_returns_err_on_response_incomplete_not_truncated_ok). Nothing previously assertedNone, so no existing test needed editing.Witness:
complete_maps_finish_reason_to_stop_and_tool_calls.bedrock.rs— the SigV4 signer no longer slices a caller-supplied string.let date = &input.amz_date[..8]panics on a value shorter than 8 bytes or whose byte 8 is not a char boundary;SigningInputispub(crate)withamz_datedocumented as "fixed in tests", so the invariant lived only in prose. Nowget(..8).unwrap_or(input.amz_date). Both botocore golden vectors still pass byte-for-byte — well-formed dates are unaffected.Witness:
sign_does_not_panic_on_a_malformed_amz_date.credential.rs—CredentialsFilegets a redactingDebug. It derivedDebugover aBTreeMap<String, String>of plaintext provider keys, so any{:?}dumped every secret in the file. Hand-written impl printingpath, the provider count, and"<redacted>";Debugdropped fromCredentialsFileData(it keepsSerialize/Deserializefor the TOML round-trip). Mirrorsstella-media/src/credential.rs. Nothing in the workspace formats either type with{:?}.Witness:
debug_never_prints_a_stored_key.openai.rs—prompt_cache_keygets the process-wide sequence suffix. It wasstella-<pid>-<nanos>, the exact constructionzai.rsdocuments as insufficient and fixes with aSESSION_SEQ: AtomicU64. Siblings built inside one nanosecond shared a cache-routing key, undoing the shard separation the field exists to provide. Same four-line atomic, nowstella-<pid>-<nanos>-<seq>.Witness:
siblings_minted_in_the_same_nanosecond_get_distinct_prompt_cache_keys— the formatting half is split from the clock read (prompt_cache_key_at) precisely so the test can hand it the same instant twice. A loop over real constructions is not a witness here: the clock is fine-grained enough that 64 back-to-back builds never collided on the old code, which is why the deterministic form exists.distinct_provider_constructions_get_distinct_prompt_cache_keysremains as the end-to-end pin.provider_listing.rs— pagination cursors are percent-encoded.format!("{base}/v1/models?limit=1000&after_id={id}")and the GeminipageTokentwin interpolated provider-controlled opaque tokens raw; a value containing&,#, or+truncates the query, injects a parameter, or decodes wrong — and the failure mode is a wrong or empty page, not an error. Both now build throughreqwest::Url::parse+query_pairs_mut().append_pair(...).get_json's&strsignature is unchanged.Witness:
fetch_anthropic_percent_encodes_a_cursor_carrying_query_syntax(cursorclaude-a&limit=1#x). The existingfetch_anthropic_paginates_with_after_id_and_sends_auth_headersstill passes unchanged.provider_listing.rs— exhaustingMAX_PAGESis an error, not a quiet stop.fetch_anthropicandfetch_geminibroke out of theforand returned the partial list as success, so a provider legitimately serving more than 10 pages produced a silently truncated catalog and a model picker missing rows. Both now track whether the loop ended onnext == Noneand returnErr(String)naming the cap otherwise. This is a live-path behavior change the issue's Fix text asks for explicitly.Witness:
pagination_that_never_terminates_errors_instead_of_truncating.Non-behavioral
attachment.rs— no more decode-then-re-encode.resolve_onedecoded an inlineAttachmentSource::Datapayload and then immediatelyBASE64.encoded the same bytes back for every binary kind. The decode stays as validation; the caller's original string is now threaded through to the Image/Pdf/Audio/Video arms, withBASE64.encode(&bytes)kept as the fallback for thePathsource.bytesis still needed for the Text arm'sinline_text.No witness: the
STANDARDengine requires canonical padding and rejects both whitespace and trailing bits, so anything that decodes re-encodes to the identical string — this is a pure allocation/CPU saving with no observable output change.inline_data_source_passes_through_decodedalready pins the value.zai.rs/zai/tests.rs—s/behaviour/behavior/at the two sites the issue names (both inside the same grok-4 reasoning-passthrough doc comment). Thebehaviouroccurrences indocs/design/are outside this issue's scope and were left alone.Deferred
Eight items are untouched. Each is deferred for a reason the issue itself states, not for convenience.
attachment.rs:78— blockingstd::fs::readinresolve_one. The issue files it under its own no-async-change rule. Makingwire_partsasync is cross-cutting across five adapter call sites and their request-build paths, and the ingest-sidePath→Datahydration lives outsidestella-modelentirely. Not verifiable by any local test.credential.rs:236— warn or refuse on a group/world-readablecredentials.toml. Needs a new advisory channel on a public API (a newCredentialErrorvariant or a returned advisory) plus a refuse-vs-warn-vs-chmod decision the issue explicitly assigns to the owner.credential.rs:63— zeroizeApiKeyon drop. Requires a new workspace dependency (zeroize/secrecy), which the issue puts out of scope and AGENTS.md gates behind a licensing /cargo denydecision. Doing only theprovider_listing.rs:370bearer_authhalf would leave the stated threat model unchanged.openai.rs:485/gemini.rs:572/vertex.rs:95/bedrock.rs:548— implementProvider::complete_observed. Confirmed (onlyanthropic.rsandzai.rsimplement it), but the fix rewritesaggregate_openai_streamandaggregate_gemini_streamsignatures and changes concurrency-visible behavior on every turn for four adapters; Bedrock cannot participate until it streams. Too large and too behavior-central for a cleanup batch.cache_economics.rs:186— theCacheWarmth/is_cache_expired_rewriteTTL-boundary disagreement. Both behaviors are explicitly test-pinned (warmth_counts_down_and_saturates_to_expired,expired_rewrite_needs_both_a_cold_gap_and_an_actual_write); the issue's own Fix says picking one contract is a product decision.sse.rs:91—MAX_BUFFERED_EVENT_BYTES+ a consumed cursor. Deferred by the Fix text as a change to the decoder's error surface, which every adapter maps onto its ownProviderErrorclassification. Choosing the cap value and the new terminal error is a design call with workspace-wide reach.zai.rs:492/anthropic.rs:345/openai.rs:236/bedrock.rs:301— theunreachable!()WirePartarms. Each arm sits behind a*_CAPSconst fixed in the same file, so no test can reach them without editing the caps — there is no witness that fails on the old code. It also requires inventing four adapter-specific degrade wordings.zai.rs:34— rename tochat_completions/ChatCompletionsProviderand replace theself.idstring gates with aDialectQuirksstruct. The Fix text calls it "a public-API rename touching stella-cli — squarely out of an audit's remit". It would also collide with this PR'szai.rsedits.resolve_forchange). The issue marks this sub-item deferred to the owner: choosing betweenNone/0.0, a cross-provider fallback, or a hard error is a metering-policy decision. Today that case resolves toNone→cost_usd 0.0, which the three new scoping witnesses pin.Issue checklist
anthropic.rs:507— only the last system message reaches the wireanthropic.rs:36— unscopedCatalog::resolvefor pricingattachment.rs:78— blockingstd::fs::readon the async request-build pathcredential.rs:236— no permission check oncredentials.tomlcredential.rs:63—ApiKeynot zeroized on dropopenai.rs:485— four adapters inherit the silentcomplete_observeddefaultopenai.rs:568—finish_reasonhard-codedNoneattachment.rs:90— decode-then-re-encode of inline base64bedrock.rs:901— panickingamz_date[..8]slicecache_economics.rs:186— TTL-boundary disagreementcredential.rs:215— derivedDebugover plaintext keysopenai.rs:63— collidingprompt_cache_keyprovider_listing.rs:226— raw cursor interpolation into query stringsprovider_listing.rs:87—MAX_PAGESexhaustion indistinguishable from a clean finishsse.rs:91— unboundedSseDecoderbuffer + quadratic drainzai.rs:492—unreachable!()WirePartarmszai.rs:34— vendor-named shared adapter + string-equality identity gateszai.rs:351—behaviour→behaviorVerification
From the worktree:
Workspace-wide clippy and tests were run (not just
stella-model) because droppingDebugfromCredentialsFileis visible tostella-cli. No pre-existing failures were encountered.Files also being touched by #557
stella-model/src/credential.rsandstella-model/src/bedrock.rsare shared with #557. The edits here are deliberately narrow: one struct'sDebug(plus one test) incredential.rs, and one line insigv4::sign(plus one test) inbedrock.rs.