Skip to content

release: 0.3.0 — request models, OAuth auth, DiscoGen cost metadata - #15

Merged
quantumdark merged 46 commits into
mainfrom
development
Aug 29, 2026
Merged

release: 0.3.0 — request models, OAuth auth, DiscoGen cost metadata#15
quantumdark merged 46 commits into
mainfrom
development

Conversation

@quantumdark

@quantumdark quantumdark commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Releases 0.3.0 to PyPI (discolike and discolike-cli). Docs at docs.discolike.com already describe 0.3.0.

Highlights from CHANGELOG 0.3.0:

  • Breaking: every SDK method that sends parameters takes a generated request model (discolike.requests) instead of keyword arguments; CLI builds the same models and maps validation errors to exit 2
  • OAuth credentials and bearer auth: discolike auth login loopback flow, cross-process refresh safety, client registration reuse
  • estimated_cost and cost_metadata on JobStatus
  • segment_file, contract check of request models against the spec in both directions

Release steps

  1. Merge this PR (merge commit, as for 0.2.0)
  2. Create GitHub release v0.3.0 on main, which triggers publish.yml for both packages

Greptile Summary

This release migrates SDK and CLI calls to generated request models, adds OAuth login and refresh support, exposes DiscoGen cost metadata, and expands contract validation.

  • Adds browser-based PKCE login, persisted credentials, token refresh, and client-registration reuse.
  • Reworks synchronous and asynchronous resources around generated request objects and explicit wire serialization.
  • Adds request-generation tooling, contract checks, tests, documentation, and aligned 0.3.0 package metadata.

Confidence Score: 4/5

The OAuth upload retry defect should be fixed before merging because token refresh can break valid operations using non-seekable file streams.

The new authentication flow retries the original request after a 401, but upload methods accept streams that cannot necessarily be rewound, allowing the retry to fail with a consumed or empty multipart body.

Files Needing Attention: packages/discolike/src/discolike/_auth.py, packages/discolike/src/discolike/resources/enrich.py, packages/discolike/src/discolike/resources/match.py

Important Files Changed

Filename Overview
packages/discolike/src/discolike/_auth.py Adds API-key and OAuth authentication flows with proactive refresh and 401 replay; replaying streamed upload requests is unsafe for non-seekable inputs.
packages/discolike/src/discolike/_client.py Adds credential-aware client construction and migrates top-level workflows to generated request models while maintaining sync/async parity.
packages/discolike/src/discolike/_config.py Adds atomic credential and OAuth registration persistence while preserving explicit-key and environment precedence.
packages/discolike-cli/src/discolike_cli/auth.py Implements browser PKCE login, loopback callbacks, client-registration reuse, API-key fallback, status, and logout behavior.
packages/discolike/src/discolike/_generated/requests.py Introduces generated Pydantic request models used across the SDK and CLI.
scripts/gen_requests.py Generates request models from OpenAPI and supports deterministic drift checks for CI.
packages/discolike/src/discolike/resources/enrich.py Migrates append and segmentation operations to request models; its BinaryIO upload paths expose the OAuth replay issue.
packages/discolike/src/discolike/resources/match.py Migrates matching operations to request models; bulk upload with a non-seekable stream can fail during OAuth replay.
.github/workflows/contract.yml Extends contract CI to verify that committed generated request models match the selected specification.

Fix all with Greploop Fix All in Claude Code Fix All in Codex

Prompt To Fix All With AI
### Issue 1
packages/discolike/src/discolike/_auth.py:112-113
**OAuth replay consumes upload streams**

When an OAuth-authenticated `append`, `segment_file`, or bulk-match upload uses a non-seekable `BinaryIO` and receives a 401, the auth flow refreshes the token and yields the same consumed multipart request again, causing the retry to raise a consumed-stream error or send an empty upload.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "release: 0.3.0" | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

Context used (4)

yudelevi and others added 30 commits August 27, 2026 12:15
Request models need to send exactly what the caller set: server
defaults must keep governing omitted fields, and PUT /llm-providers
relies on an explicit null api_key meaning "keep the stored key", so
to_wire() excludes unset fields rather than None values. Unknown fields
pass through so the SDK can lag the platform without blocking callers.
datamodel-code-generator joins the SDK dev group for the generator.
The two users of ignore_params existed only to hide a kwarg-to-body
rename (email.find_batch) and a shared wrapper over two routes
(segment). Both go away once request models carry the exact field
names, so the stamp shrinks to (method, path, openapi).

Moved the two per-route exclusions into check_contract.py's global
IGNORE_PARAMS set so the contract check keeps passing until Task 14
rewrites the request side.
The platform's pydantic request models import geo/propelauth/litellm
and ship no shared package, so the SDK generates its own from the
OpenAPI spec instead. Query-param routes get a synthesized
<Resource><Method>Params schema; JSON-body routes reuse the platform
component name. anyOf-null and per-item constraints are folded so
datamodel-codegen emits plain annotated fields instead of RootModel
wrappers, and deprecated params stay out as they have since 0.1.1.
Popping from a set left the transitive schema order dependent on
PYTHONHASHSEED, so the generated class order could differ between runs
and --check would report drift against a file nobody had touched. Only
one schema is pulled transitively today, so this was latent rather than
live, but it arms itself the moment a request component gains two
sibling refs.
Generated from the dev spec: prod still exposes the discogen, validate,
contacts-generate and provider bodies under FastAPI's inline Body_*
names, which dev has already renamed. The release PR to main stays
blocked by gen_requests.py --check until that deploys, the same gate
check_contract.py imposes today.
Enum-valued params (match mode, public link source) are now rejected
locally with a pydantic ValidationError instead of a server 422.
min_match_confidence's 50-100 range is now enforced client-side; the
hand-written 'only send it when set' branch goes away because to_wire
already sends only what the caller set.
The 39-parameter discover/search signatures collapse into
ContactFilters / ContactsSearchParams, and bulk_match now validates
each query item instead of forwarding raw dicts.
find_batch drops its contacts= shim; FindEmailBatchRequest.requests is
the platform's own field name and validates each contact up front.
save_results' action enum and the tag/name length limits now fail
locally; query_id stays a keyword argument since path params are not
part of the body schema.
LLMProviderUpdateRequest carries the platform's required-but-nullable
api_key, so the hand-rolled 'always send api_key' branch is gone:
to_wire sends whatever the caller set, null included.
Domain/persona list bounds (1-10000), context_mode and
search_context_size enums are checked before the job is submitted.
The 41-parameter signature was copied three times (resource, sync
client, async client); DiscoverParams is generated from the spec, so
new filters no longer need four hand edits.
…_file

GET /segment and POST /segment are different routes with different
params; one wrapper over both is why ignore_params existed. Each gets
its own method and model. domains is a comma-separated string, as the
spec declares it, instead of a list the SDK joined behind the caller's
back.
check_contract.py reads each method's request model off its annotation
and diffs the field set against the spec's query params and body
properties, so a platform field the SDK never declared fails the run
just like an SDK field the platform dropped. gen_requests.py --check
catches the generated file going stale on the same schedule.

Regenerating for that new gate reorders two classes in requests.py; the
line multiset is unchanged.
…exit 2

Bad option and --param values now surface as the same ValidationError
exit-2 path a server 422 takes, before any request is sent. A bare
--param string for a list field is wrapped the way the API reads it.
Option values are validated against the spec before the request goes
out; --param keeps working as the escape hatch and now also validates
known keys instead of forwarding them blind.
Seniority/department/industry values are checked against the spec's
enums locally; the tests that relied on made-up values now use real
ones.
llm-providers update keeps sending an explicit null api_key: the
platform reads null as 'keep the stored key' and the field is required.
…odels

segment dispatches to segment/segment_file explicitly instead of
relying on the removed SDK wrapper; --domain values are joined into the
comma-separated string the spec declares. call_typed goes with the
last call site: the SDK no longer raises TypeError for unknown kwargs,
and unknown --param keys pass through by design.
Breaking SDK surface (models instead of kwargs) warrants the minor
bump; CLI pins the SDK exactly as before.
…mple

Missed in the 0.3.0 doc sweep — discover() now takes DiscoverParams,
not icp_text= directly.
…ange, and dev-spec drift check

CHANGELOG was silent about two 0.3.0 behavior changes callers will hit:
append now requires --dataset client-side, and GET /segment sends
query_id as repeated params instead of comma-joined. README/CONTRIBUTING
didn't explain that gen_requests.py --check (like check_contract.py)
only passes against the dev spec until the platform deploys prod.
…rated file

--check was leaking ruff's "Fixed N errors" lines into its own stdout
because the repo sets show-fixes=true; pass --no-show-fixes explicitly.
Also, importing discolike.resources at module scope meant deleting the
committed requests.py crashed with a traceback instead of the intended
"stale; run: ..." message, since check() never got a chance to guard for
it. Deferred those imports into the functions that need them so a missing
file is reported cleanly.
…d missing annotations

test_email_find_batch_rejects_more_than_500_before_any_request duplicated
the CLI test above it without exercising the guard its name claimed
(MAX_BATCH_CONTACTS fires first). Retargeted it as a direct
FindEmailBatchRequest.model_validate test. Also: discover.py's ANN401
noqa said the kwargs are forwarded to typed resource/client methods, but
they go to build_request as a dict; fixed the comment. Added -> None
return annotations to test_gen_requests.py and test_requests_module.py
to match the rest of the suite, and dropped an unused routes fixture arg.
The deferred-import workaround for a deleted committed requests.py
traded a stale repo convention violation for a marginal UX improvement.
A missing _generated/requests.py means a broken checkout; the
ModuleNotFoundError is the honest failure and imports belong at module
scope. Keeps the --no-show-fixes fix from the same pass.
…g identity fields

EmailJobResult.result is EnumerationOutput | ValidationOutput | None because
the model is shared by find and verify batches. The example read find-only
fields off the union, which ty rejects; isinstance narrowing replaces the
getattr workaround.
--contact already rejects blank parts locally, but a CSV row with an empty
first_name/last_name/domain was blanked to "" and sent to the API, which
rejected it server-side. The spec puts no min_length on those fields, so
the generated model cannot catch it; the CSV reader has to.
feat!: generated request models validate requests before the wire (0.3.0)
DiscoGen-family status responses carry per-model usage plus, since the
platform started reporting it, a search_provider entry with the BYOS
query count and cost. Without typed fields callers had to dig them out
of to_dict(). search_calls on the model entries only counts built-in
search and reads 0 on every BYOS run, so the docs point readers at
search_provider.queries_executed instead (ticket 900).
yudelevi and others added 16 commits August 28, 2026 09:33
The REST API now accepts PropelAuth bearer tokens, so the SDK needs a
credential that can outlive a one-hour access token. Auth moves from a
static X-discolike-key header into an httpx2.Auth subclass that picks
the header per credential, refreshes an OAuth token before expiry and
once after a 401, and serialises concurrent refreshes so a burst of
requests rotates the refresh token exactly once.

Refreshes are yielded through the calling client's own transport rather
than a second HTTP client, which keeps sync/async symmetric and lets
MockTransport drive them in tests. Rotated tokens are written back only
when the credential came from the config file; an injected auth= stays
the caller's responsibility.

Discolike(api_key=...), resolve_api_key, the config file location and
the api_key JSON shape are unchanged; OAuth adds an "oauth" object under
auth_method="oauth". Version 0.4.0.
`discolike auth login` now defaults to a browser login: discover the
authorization server through the API, register a public client via DCR,
run PKCE authorization-code against a loopback redirect, and save the
resulting OAuth credential. The redirect URI is registered with the
actual bound port (random by default, --port to pin for SSH forwarding)
so it works whether or not the server relaxes loopback port matching.

The API-key path stays reachable through --api-key or --method api_key,
which preserves the prompt and the existing stderr JSON. auth status
adds a method field and, for OAuth, expiry information.
Two processes holding the same refresh token near expiry would both
refresh; the second fails at the authorization server (rotation) and
the last writer could overwrite the newer token pair.

save_config now writes to a temp file in the config dir and renames it
into place, so a reader never sees a partial file. DiscolikeAuth, when
the credential came from the config file, re-reads the file under the
refresh lock and adopts a fresher credential written by another process
instead of refreshing with a refresh token that is already spent.
Every login registered a fresh DCR client, and PropelAuth remembers
consent per client_id, so users saw the consent screen on every
`auth login`. The registration (client_id, exact redirect URI, issuer)
is now stored under "oauth_client" in the config file, kept across
credential writes, and reused when the next login discovers the same
issuer and can bind the same loopback port again. PropelAuth matches
the redirect URI literally, port included, so a busy port, a different
issuer, or an explicit --port that differs all fall back to a fresh
registration. `auth logout` still deletes the whole file.
PropelAuth remembers consent per client_id, so wiping the registration
on logout put the consent screen back in front of the user on the next
login. The registration is a public PKCE client with no secret, so
logout now drops only the credential and leaves "oauth_client" in the
config file; the file is removed only when nothing else remains.
load_config already degrades a corrupt file to an empty dict, but a
file with auth_method "oauth" and a missing or malformed "oauth" object
(or a malformed "oauth_client") escaped as a raw KeyError/TypeError/
ValueError from the client constructor. Follow the same rule: such a
section reads as absent, so callers get the usual "run discolike auth
login" AuthenticationError instead of a traceback.
A stored client registration that PropelAuth has since forgotten made
every login fail the same way: the authorize step came back with
invalid_client/unauthorized_client, or the code exchange did, and the
CLI kept reusing the dead client_id. Those two error codes now mark the
registration as dead; when the registration was a reused one it is
discarded, a fresh client is registered, and the browser flow runs
once more. A freshly registered client that is rejected, or a second
failure, surfaces as the normal LoginError. Other callback errors such
as access_denied keep the registration and fail as before.

The token endpoint's error code is now carried on OAuthError.error so
the CLI can distinguish invalid_client from any other rejection.
…ens from error payloads

A forged /callback?error=invalid_client from anything that can reach the
loopback port could evict the stored client registration before the state
check ran. State is now verified first, so only the browser session the
CLI started can affect login state.

Token-response parsing errors attached the raw response, access token
included, as exc.payload; SDK consumers that log payloads would leak a live
token. Token fields are stripped before the exception is raised.
feat: OAuth login for SDK and CLI (0.3.0)
Comment on lines +112 to +113
_set_bearer(request, latest)
yield request

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 OAuth replay consumes upload streams

When an OAuth-authenticated append, segment_file, or bulk-match upload uses a non-seekable BinaryIO and receives a 401, the auth flow refreshes the token and yields the same consumed multipart request again, causing the retry to raise a consumed-stream error or send an empty upload.

Knowledge Base Used: Client configuration and authentication

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/discolike/src/discolike/_auth.py
Line: 112-113

Comment:
**OAuth replay consumes upload streams**

When an OAuth-authenticated `append`, `segment_file`, or bulk-match upload uses a non-seekable `BinaryIO` and receives a 401, the auth flow refreshes the token and yields the same consumed multipart request again, causing the retry to raise a consumed-stream error or send an empty upload.

**Knowledge Base Used:** [Client configuration and authentication](https://app.greptile.com/discolike/-/custom-context/knowledge-base/discolike/discolike-python/-/docs/client-configuration-and-authentication.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

@quantumdark
quantumdark merged commit c21c269 into main Aug 29, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants