Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
3c11530
feat(sdk): add DiscolikeRequest base for generated request models
yudelevi Aug 27, 2026
e3b9676
refactor(sdk): drop ignore_params from api_route
yudelevi Aug 27, 2026
827f8ee
build: add gen_requests.py to derive request models from the spec
yudelevi Aug 27, 2026
5923b20
fix: make prune walk refs in a deterministic order
yudelevi Aug 27, 2026
67d80b4
feat(sdk): commit generated request models and discolike.requests
yudelevi Aug 27, 2026
f268a0d
feat(sdk)!: companies methods take generated request models
yudelevi Aug 27, 2026
9050ed3
feat(sdk)!: match methods take generated request models
yudelevi Aug 27, 2026
94b74f7
feat(sdk)!: contacts methods take generated request models
yudelevi Aug 27, 2026
0a48e02
feat(sdk)!: email methods take generated request models
yudelevi Aug 27, 2026
d9401f9
feat(sdk)!: queries methods take generated request models
yudelevi Aug 27, 2026
836b270
feat(sdk)!: provider methods take generated request models
yudelevi Aug 27, 2026
d505d06
feat(sdk)!: discogen and validate_icp take generated request models
yudelevi Aug 27, 2026
a746741
feat(sdk)!: discover/count take generated request models
yudelevi Aug 27, 2026
eb4331c
feat(sdk)!: append/segment take generated request models; add segment…
yudelevi Aug 27, 2026
6d8436f
ci: check request models against the spec in both directions
yudelevi Aug 27, 2026
8da7499
feat(cli): build request models from options; map pydantic errors to …
yudelevi Aug 27, 2026
80a0d49
feat(cli): company, match, discover and count build request models
yudelevi Aug 27, 2026
e0fa9f7
feat(cli): contacts and email commands build request models
yudelevi Aug 27, 2026
fb88ba7
feat(cli): queries and provider commands build request models
yudelevi Aug 27, 2026
b4f5853
feat(cli): discogen, validate-icp, append and segment build request m…
yudelevi Aug 27, 2026
950748c
release: 0.3.0 request models
yudelevi Aug 27, 2026
2c00fc3
fix(docs): convert stale discover() call in README error-handling exa…
yudelevi Aug 27, 2026
ec6e8dc
docs: document append --dataset requirement, segment query_id wire ch…
yudelevi Aug 27, 2026
54197a2
fix(gen_requests): silence ruff --fix noise and handle a missing gene…
yudelevi Aug 27, 2026
f244d96
test: retarget email-batch limit test at the model, fix stale noqa an…
yudelevi Aug 27, 2026
c649548
revert gen_requests missing-file guard: keep imports at module top
yudelevi Aug 27, 2026
3b6762e
fix(examples): narrow batch result to EnumerationOutput before readin…
yudelevi Aug 27, 2026
a8de867
fix(cli): reject empty cells in --contacts-file before submitting
yudelevi Aug 27, 2026
c387d2b
Merge pull request #13 from discolike/feat/request-models
yudelevi Aug 27, 2026
1a55ddc
feat(sdk): expose estimated_cost and cost_metadata on JobStatus
Aug 28, 2026
3d71506
feat(sdk): OAuth credentials and bearer auth flow
yudelevi Aug 28, 2026
5972d50
feat(cli): auth login via OAuth loopback flow
yudelevi Aug 28, 2026
e59bc5d
test(sdk): rename test_auth.py to avoid basename clash with CLI tests
yudelevi Aug 28, 2026
ce60ddd
fix(sdk): avoid cross-process refresh token race
yudelevi Aug 28, 2026
5a48c72
feat(cli): reuse the registered OAuth client across logins
yudelevi Aug 28, 2026
0bf3b1e
docs: fold unreleased 0.3.0 changelog into 0.4.0
yudelevi Aug 28, 2026
091436e
fix(cli): keep the OAuth client registration across logout
yudelevi Aug 28, 2026
c9354b5
fix(sdk): treat a malformed oauth config section as no credential
yudelevi Aug 28, 2026
608185f
fix(cli): re-register when PropelAuth rejects the stored OAuth client
yudelevi Aug 28, 2026
fa9edb4
chore: version is 0.3.0 — the unreleased 0.3.0 absorbs OAuth, no sepa…
yudelevi Aug 28, 2026
b00f9d0
chore(sdk): note the planned switch to authlib once it ships httpx2 s…
yudelevi Aug 28, 2026
45b2b46
refactor(sdk): drop unused _oauth.refresh helper
yudelevi Aug 28, 2026
b2d5e3e
refactor(sdk): drop resolve_api_key, superseded by resolve_credential
yudelevi Aug 28, 2026
0548536
fix(auth): check callback state before acting on an error, redact tok…
yudelevi Aug 28, 2026
c273697
Merge pull request #14 from discolike/feat/oauth
quantumdark Aug 29, 2026
c540e2f
release: 0.3.0
Aug 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/contract.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ jobs:
run: |
if [ "$BASE_REF" = "development" ] && [ -n "$DEV_SPEC_URL" ]; then
uv run python scripts/check_contract.py --spec-url "$DEV_SPEC_URL"
uv run python scripts/gen_requests.py --check --spec-url "$DEV_SPEC_URL"
else
uv run python scripts/check_contract.py
uv run python scripts/gen_requests.py --check
fi
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,44 @@
# Changelog

## 0.3.0 (2026-08-29)

- SDK: OAuth login. `Discolike(auth=...)` / `AsyncDiscolike(auth=...)` accept an `ApiKeyCredential` or `OAuthCredential` (both exported from `discolike`); `api_key=`, `DISCOLIKE_API_KEY`, and the config file keep working unchanged, and `auth=` wins over all of them. OAuth credentials send `Authorization: Bearer`, refresh proactively within 60s of expiry and once more after a 401, and write rotated refresh tokens back to the config file when they were loaded from it (an injected `auth=` is never persisted). A refresh that fails raises `AuthenticationError("OAuth session expired; run `discolike auth login`")`. Config file gains the shape `{"auth_method": "oauth", "oauth": {...}}` next to the existing `api_key` shape.
- SDK: `http_client=` now has its `.auth` set by the SDK (the `X-discolike-key` header moved from a static default header into an `httpx2.Auth`); an `auth` already set on a user-supplied client is replaced.
- CLI: `discolike auth login` now logs in through the browser by default (PKCE authorization-code flow against the platform's OAuth server, loopback redirect on `127.0.0.1`). `--no-browser` prints the URL only, `--port` pins the loopback port for SSH forwarding, and `--method api_key` (or `--api-key KEY`) keeps the API-key flow, including the prompt. Login-flow failures (timeout, denied consent, state mismatch) exit 1 with `{"error": "LoginError", ...}` on stderr.
- CLI: `discolike auth login` remembers the OAuth client it registered (`oauth_client` in the config file) and reuses it on the next login for the same server, so the browser consent screen is only asked once per machine. `auth logout` drops the credential but keeps the registration (a public PKCE client, not a secret), so the next login skips consent too. If PropelAuth no longer recognises the stored client (`invalid_client` / `unauthorized_client`), login registers a fresh one and retries once.
- CLI: `discolike auth status` adds `method` (`api_key` / `oauth`); for OAuth it reports `expires_at` and `expired` instead of a masked key.

- SDK: `JobStatus` gains `estimated_cost` and `cost_metadata` for DiscoGen-family jobs (`discogen`, `validate_icp`, contacts generate). `cost_metadata` has one entry per `provider/model` and a `search_provider` entry with `queries_executed` / `queries_succeeded` / `est_cost_usd` when a BYOS search provider ran. `search_calls` on the model entries counts only the model's built-in search tool and is `0` on every BYOS run; read `search_provider.queries_executed` to confirm web search happened.

### Request models

- SDK (breaking): every request-taking method now takes a single request model instead of keyword arguments, and validates it locally before any HTTP call — a bad enum value, an out-of-range number, or a missing required field raises `pydantic.ValidationError` instead of a server 422. Models live in `discolike.requests` and are generated from the platform OpenAPI spec (`scripts/gen_requests.py`); query-param routes use `<Resource><Method>Params` (`MatchCompanyParams`, `ContactsSearchParams`, `DiscoverParams`, `CountParams`, `AppendParams`, `SegmentParams`, ...) and JSON-body routes use the platform's own names (`FindEmailRequest`, `ContactFilters`, `DiscoGenProcessRequest`, `UpdateQueryRequest`, ...). Path params and file uploads stay keyword arguments next to the model. Unknown fields pass through to the wire, so the SDK never blocks a platform field it does not know about yet.

```python
# before
client.match.company(name="Acme Inc", city="Austin", min_match_confidence=80)
client.email.find_batch(contacts=[{"first_name": "Ada", "last_name": "Lovelace", "domain": "acme.com"}])
client.queries.update(query_id="q3", query_name="New Name")
client.segment(domains=["acme.com", "beta.com"], max_segments=5)
client.segment(file="domains.csv", domain_column="domain")

# after
from discolike.requests import FindEmailBatchRequest, MatchCompanyParams, SegmentFileParams, SegmentParams, UpdateQueryRequest

client.match.company(MatchCompanyParams(name="Acme Inc", city="Austin", min_match_confidence=80))
client.email.find_batch(FindEmailBatchRequest.model_validate({"requests": [{"first_name": "Ada", "last_name": "Lovelace", "domain": "acme.com"}]}))
client.queries.update(UpdateQueryRequest(query_name="New Name"), query_id="q3")
client.segment(SegmentParams(domains="acme.com,beta.com", max_segments=5))
client.segment_file(SegmentFileParams(domain_column="domain"), file="domains.csv")
```

- SDK (breaking): `segment` is split into `client.segment(SegmentParams)` (`GET /segment`, `domains` is the comma-separated string the API takes) and `client.segment_file(SegmentFileParams, file=...)` (`POST /segment`). `email.find_batch` drops its `contacts=` alias for the platform's `requests` field. `append` requires `dataset`, matching the platform.
- SDK (breaking): `GET /segment` sends `query_id` as repeated `query_id=` params (the spec declares it an array) instead of the old comma-joined single value.
- SDK: deprecated parameters (`nl_match`, `min_score`, `negate_domain`, `exact_match`, `vendor`, `revenue_range` on contacts, `negate_icp_text`) are not part of the generated models; they still pass through as extra fields if set explicitly.
- SDK: new `DiscolikeRequest` base (`discolike.DiscolikeRequest`) with `to_wire()`, which sends exactly the fields you set — an explicit `None` goes out as `null` (this is how `llm_providers.update` keeps the stored API key), and unset fields are omitted so server defaults keep governing.
- CLI: request models are built from the same options as before, so no flags change. Two behavior changes: `--param KEY=VALUE` with an unknown key is forwarded to the API (previously exit 2), and any option or `--param` value outside the spec (an unknown `--department`, `--max-records` below the floor, `--match loose`) exits 2 with `{"error": "ValidationError", ...}` on stderr before the request is sent. `discolike append` without `--dataset` now fails this same client-side validation (was optional before; `AppendParams` requires it).
- CI: the contract job also runs `scripts/gen_requests.py --check`, so the committed models fail the build when the platform spec moves.

## 0.2.0 (2026-08-21)

- SDK + testkit (breaking): migrated from `httpx` to [`httpx2`](https://github.com/pydantic/httpx2), Pydantic's maintained continuation of httpx, for timely security updates. `httpx` types are part of the public surface (`http_client=`, `with_options(timeout=)`, the testkit `Handler` alias), so callers must swap `import httpx` for `import httpx2` and pass `httpx2.Client` / `httpx2.AsyncClient` / `httpx2.Timeout`. Note httpx2 verifies TLS against the OS trust store via `truststore` instead of bundled `certifi` roots.
Expand Down
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ CI also validates SDK routes against the live DiscoLike OpenAPI spec
(`scripts/check_contract.py`). PRs from forks are checked against the
production spec.

CI also runs `scripts/gen_requests.py --check` against the dev spec
(`https://api.dev.discolike.com/v1/openapi.json`) — the committed request
models in `discolike.requests` track dev, not prod. The prod spec lags, so
both `check_contract.py` and `gen_requests.py --check` against prod stay red
until the platform deploys; don't regenerate against prod to "fix" it.

## Reporting bugs

Open a GitHub issue with the package name, version, and a minimal
Expand Down
58 changes: 45 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,16 @@ client = Discolike(api_key="dl_...") # or pass it explicitly

```python
from discolike import Discolike
from discolike.requests import DiscoverParams

client = Discolike()

companies = client.discover(
icp_text="Cybersecurity for SMBs, managed IT services, endpoint protection",
country=["US"],
max_records=25,
DiscoverParams(
icp_text="Cybersecurity for SMBs, managed IT services, endpoint protection",
country=["US"],
max_records=25,
)
)
for company in companies:
print(company.domain, company.name, company.similarity)
Expand All @@ -97,10 +100,14 @@ for company in companies:
Run DiscoGen research over a set of domains and wait for the result:

```python
from discolike.requests import DiscoGenProcessRequest

job = client.discogen.process(
query="Recent funding rounds and headcount growth",
domains=["stripe.com", "adyen.com"],
web_search=True,
DiscoGenProcessRequest(
query="Recent funding rounds and headcount growth",
domains=["stripe.com", "adyen.com"],
web_search=True,
)
)
result = job.wait()
print(result.results)
Expand All @@ -109,14 +116,18 @@ print(result.results)
Size a segment before pulling it:

```python
total = client.count(phrase_match=["book a demo"], country=["US"])
from discolike.requests import CountParams

total = client.count(CountParams(phrase_match=["book a demo"], country=["US"]))
print(total.count)
```

Pull a full company profile:

```python
profile = client.companies.data(domain="stripe.com")
from discolike.requests import CompaniesDataParams

profile = client.companies.data(CompaniesDataParams(domain="stripe.com"))
```

The client is a context manager if you want deterministic cleanup:
Expand All @@ -133,10 +144,11 @@ Every resource has an async twin on `AsyncDiscolike`:
```python
import asyncio
from discolike import AsyncDiscolike
from discolike.requests import DiscoverParams

async def main() -> None:
async with AsyncDiscolike() as client:
companies = await client.discover(icp_text="B2B SaaS for logistics", max_records=10)
companies = await client.discover(DiscoverParams(icp_text="B2B SaaS for logistics", max_records=10))
print([c.domain for c in companies])

asyncio.run(main())
Expand Down Expand Up @@ -192,11 +204,12 @@ Top-level commands: `discover`, `count`, `match`, `extract`, `validate-icp`, `ap
| `client.contacts` | Search, look up, match, and discover contacts at target companies |
| `client.match` | Match company names (plus phone/city/state) to domains — single or bulk CSV |
| `client.append()` | Enrich a CSV of domains with DiscoLike datasets |
| `client.segment()` | Auto-segment a list of domains |
| `client.segment()` / `client.segment_file()` | Auto-segment a list of domains (comma-separated string or CSV upload) |
| `client.validate_icp()` | Validate a domain list against an ICP definition |
| `client.queries` | Saved inclusion/exclusion lists for reusable targeting |
| `client.search_providers` / `client.llm_providers` | Manage BYOK search and LLM provider integrations for DiscoGen |
| `client.account` | Usage and quota |
| `discolike.requests` | Request models for every call — generated from the platform OpenAPI spec, validated locally before the request is sent |

All responses are typed [Pydantic](https://docs.pydantic.dev/) models.

Expand All @@ -205,11 +218,13 @@ All responses are typed [Pydantic](https://docs.pydantic.dev/) models.
Bulk operations (`match.bulk`, `segment`, `validate_icp`, `contacts.bulk_match`) return a `Job` handle instead of blocking:

```python
job = client.segment(domains=["stripe.com", "adyen.com", "checkout.com"])
from discolike.requests import SegmentParams

job = client.segment(SegmentParams(domains="stripe.com,adyen.com,checkout.com"))
result = job.wait()
```

`Job.status()` polls without blocking, `Job.cancel()` aborts, and `wait()` raises `JobFailedError` / `JobTimeoutError` on failure.
`Job.status()` polls without blocking, `Job.cancel()` aborts, and `wait()` raises `JobFailedError` / `JobTimeoutError` on failure. On DiscoGen-family jobs the returned `JobStatus` also carries `warnings`, `estimated_cost` and `cost_metadata` (per-model usage plus a `search_provider` entry when a BYOS search provider ran; `search_calls` only counts the model's built-in search).

`JobTimeoutError` is a client-side wait limit only — the task keeps running server-side (large DiscoGen runs can take hours), so call `wait()` again to resume or fetch `status()` later. Cancelled tasks still return results for every item that finished before cancellation. Send one job per list (up to 10,000 domains) rather than splitting into parallel jobs — concurrent DiscoGen jobs share your LLM provider key and slow each other down.

Expand All @@ -219,9 +234,10 @@ All errors inherit from `DiscolikeError`:

```python
from discolike import Discolike, RateLimitError, ValidationError
from discolike.requests import DiscoverParams

try:
companies = Discolike().discover(icp_text="fintech infrastructure")
companies = Discolike().discover(DiscoverParams(icp_text="fintech infrastructure"))
except RateLimitError as err:
...
except ValidationError as err:
Expand All @@ -230,6 +246,18 @@ except ValidationError as err:

`AuthenticationError`, `PlanAccessError`, `NotFoundError`, `ServerError`, and `APIConnectionError` cover the rest. Transient failures are retried automatically (3 attempts by default).

```python
import pydantic
from discolike.requests import MatchCompanyParams

try:
params = MatchCompanyParams(name="Acme", min_match_confidence=10)
except pydantic.ValidationError as err:
... # raised locally: min_match_confidence must be 50-100
```

Request models validate before anything is sent, so a bad enum value or out-of-range number never costs a round trip. Unknown fields pass through untouched.

### Configuration

| Option | Default | |
Expand All @@ -251,8 +279,12 @@ uv sync --all-packages
uv run pytest packages/discolike/tests
uv run pytest packages/discolike-cli/tests
uv run ruff check .
uv run python scripts/gen_requests.py --spec-url https://api.dev.discolike.com/v1/openapi.json # regenerate request models
uv run python scripts/gen_requests.py --check # fail on drift (CI)
```

Committed request models track the dev spec (`--spec-url https://api.dev.discolike.com/v1/openapi.json`); the prod spec lags behind, so `--check` without a `--spec-url` (or against prod) stays red until the platform deploys — don't regenerate against prod to "fix" it.

## Support & contact

- **API documentation**: [docs.discolike.com](https://docs.discolike.com)
Expand Down
12 changes: 7 additions & 5 deletions examples/discover_and_enrich.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

Two calls end to end:

1. ``client.discover(icp_text=..., country=..., max_records=...)`` finds
lookalike companies from DiscoLike's index of 80M+ business websites.
2. ``client.discogen.process(query=..., domains=[...], web_search=True)``
1. ``client.discover(DiscoverParams(icp_text=..., country=..., max_records=...))``
finds lookalike companies from DiscoLike's index of 80M+ business websites.
2. ``client.discogen.process(DiscoGenProcessRequest(query=..., domains=[...], web_search=True))``
runs an AI research prompt over the discovered domains and returns one
structured answer per company. ``job.wait()`` blocks until it finishes.

Expand All @@ -26,6 +26,8 @@
import sys

from discolike import Discolike
from discolike.requests import DiscoGenProcessRequest
from discolike.requests import DiscoverParams


def parse_args() -> argparse.Namespace:
Expand All @@ -43,15 +45,15 @@ def main() -> None:
client = Discolike()

print(f"Discovering up to {args.max_records} companies for: {args.icp!r}")
companies = client.discover(icp_text=args.icp, country=args.country, max_records=args.max_records)
companies = client.discover(DiscoverParams(icp_text=args.icp, country=args.country, max_records=args.max_records))
if not companies:
sys.exit("No companies found for that ICP - try broadening it.")
domains = [company.domain for company in companies if company.domain]
for company in companies:
print(f" {company.domain} {company.name or ''} (similarity {company.similarity})")

print(f"\nRunning DiscoGen over {len(domains)} domains: {args.query!r}")
job = client.discogen.process(query=args.query, domains=domains, web_search=True)
job = client.discogen.process(DiscoGenProcessRequest(query=args.query, domains=domains, web_search=True))
status = job.wait(timeout=args.timeout, on_poll=lambda s: print(f" status={s.status} progress={s.progress}%"))

print("\nEnriched results:")
Expand Down
11 changes: 6 additions & 5 deletions examples/find_emails_from_csv.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Reads people from a CSV with ``first_name``, ``last_name``, and ``domain``
columns (names configurable), submits them with
``client.email.find_batch(contacts=[...])`` in chunks of up to 500, waits for
``client.email.find_batch(FindEmailBatchRequest(...))`` in chunks of up to 500, waits for
each batch with ``batch.results()``, and writes the found emails plus status
to an output CSV.

Expand All @@ -22,6 +22,8 @@
from pathlib import Path

from discolike import Discolike
from discolike import EnumerationOutput
from discolike.requests import FindEmailBatchRequest

MAX_CONTACTS_PER_BATCH = 500

Expand Down Expand Up @@ -65,11 +67,11 @@ def main() -> None:
for start in range(0, len(contacts), MAX_CONTACTS_PER_BATCH):
chunk = contacts[start : start + MAX_CONTACTS_PER_BATCH]
print(f"Submitting batch of {len(chunk)} contacts ({start + len(chunk)}/{len(contacts)})...")
batch = client.email.find_batch(contacts=chunk)
batch = client.email.find_batch(FindEmailBatchRequest.model_validate({"requests": chunk}))
results = batch.results(timeout=args.timeout)
for item in results.results:
output = item.result
if output is None:
if not isinstance(output, EnumerationOutput):
# Failed jobs carry no EnumerationOutput (so no identity),
# but must not vanish from the output: keep the status and
# error so the failure is visible and countable.
Expand All @@ -85,8 +87,7 @@ def main() -> None:
}
)
continue
match = getattr(output, "result", None)
email = match.email if match is not None else None
email = output.result.email if output.result is not None else None
if output.status == "found":
found += 1
writer.writerow(
Expand Down
7 changes: 5 additions & 2 deletions examples/match_crm_contacts.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Match a CSV of CRM contacts to DiscoLike persona IDs via bulk-match.

Reads contacts from a CSV (column names are configurable), submits them to
``client.contacts.bulk_match()`` in chunks, and writes an output CSV with the
``client.contacts.bulk_match(BulkContactMatchRequest(...))`` in chunks, and writes an output CSV with the
matched ``persona_id`` and ``match_score`` per row.

Lessons baked in from production runs:
Expand Down Expand Up @@ -34,6 +34,7 @@
from urllib.parse import urlparse

from discolike import Discolike
from discolike.requests import BulkContactMatchRequest

MAX_QUERIES_PER_CALL = 500 # bulk_match accepts 1-500 queries per request

Expand Down Expand Up @@ -204,7 +205,9 @@ def main() -> None:
hits: dict[int, dict[str, Any] | None] = dict.fromkeys(row_ids)
else:
try:
job = client.contacts.bulk_match(queries=queries, limit=1)
job = client.contacts.bulk_match(
BulkContactMatchRequest.model_validate({"queries": queries, "limit": 1})
)
status = job.wait(timeout=args.timeout)
except Exception as exc: # leave the chunk un-checkpointed so a rerun retries it
failed_chunks += 1
Expand Down
Loading
Loading