Skip to content

ChatGPT Ads: reads, campaign management and conversion diagnostics - #127

Open
illia-sapryga wants to merge 7 commits into
kLOsk:mainfrom
illia-sapryga:feat/chatgpt-ads
Open

illia-sapryga wants to merge 7 commits into
kLOsk:mainfrom
illia-sapryga:feat/chatgpt-ads

Conversation

@illia-sapryga

@illia-sapryga illia-sapryga commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Rebased onto main (0.41.2, after #134 and #130). The commits are the ChatGPT Ads module, the review fixes, two fixes from the live run, then _PLATFORM_SETUP, health_check and plugin-sync commits that match what main does for Microsoft.

ChatGPT ads went self-serve in OpenAI Ads Manager, and OpenAI published an Advertiser API for it. This adds it as a new toolset, chatgpt_ads, on the same draft → preview → confirm_and_apply gate as Google Ads and Reddit.

Reads: account (reviews, spending limits, account-level blockers), campaigns, ad groups, ads, performance with breakdowns and compact mode, conversions (click-through and view-through, plus every attributed event), a conversion-setup audit with a verdict, change history, chatgpt_attribution_check against GA4, and location search.

Writes: pause / enable / archive, update_chatgpt_campaign / _ad_group / _ad, and draft_chatgpt_campaign / _ad_group / _ad. New entities are created paused.

Connection. An Advertiser API key from Ads Manager → Settings → API keys. There's no OAuth and no developer token. Each key belongs to exactly one ad account, so ad_account_id on a tool only picks which configured key to use. Config is chatgpt_ads.api_key (or OPENAI_ADS_API_KEY) plus an optional chatgpt_ads.accounts map for agencies. CredentialsProvider gains chatgpt_ads_api_key(). A hosted provider without it doesn't get the tools, and health_check leaves chatgpt_ads out there. A local server without a key hides the tools (_PLATFORM_SETUP).

The gate. OpenAI has no validate-only mode for single resources, so the dry run works like Reddit's. It re-reads the target and refuses when a field the plan changes was edited since the preview. It also re-checks:

  • max_daily_budget, with a lifetime budget judged by its daily equivalent over the flight, re-computed whenever the budget or either date changes
  • max_bid_increase_pct
  • the landing page
  • that the key still answers for the account the preview named

The real apply runs the same checks first, reading the target by id. Creates send an Idempotency-Key, which OpenAI documents on POST /campaigns, /ad_groups and /ads: "If a matching prior create is visible, the existing object is returned instead of creating a duplicate". Live, replaying each create with the same key and body returned the same object. Images are fetched through image_fetch, so the public-only and redirect checks apply. The draft pins their sha256, and the apply uploads exactly those bytes. A new ad's file id is kept in the plan, so a retry after a lost response sends the same request.

Shared changes are in #134 now: the blocked_operations re-check in confirm_and_apply and the shared draft helpers. The name-based display hiding is gone. Request payloads, snapshots and the image pin live in ChangePlan.apply_only_payload, so changes holds only what a person approves.

Cloud contract. TOOLSETS gains chatgpt_ads, so config/toolsets.php and the runtime filter need the slug too. I updated test_toolset_slugs_match_the_cross_repo_contract.

Review fixes (801acd3, plus two found in the live run: 236bc01 and c97694d) are listed in the review reply below. The live transcript of every write is linked there.

What it does not do:

  • The Conversions API (server-side events), pixel setup, or custom audiences.
  • An adloop init step. The key goes in the config by hand, as the README says.
  • Change a campaign's bidding type or move a daily budget to lifetime. OpenAI refuses both, so the draft refuses them first.

Tested with the full suite plus tests/test_chatgpt_ads.py and tests/test_chatgpt_ads_write.py. The write tests drive the real gate against a stateful fake of the Advertiser API. It enforces the contract details: whole-object updates, status endpoints, and idempotency keys that must come back with the same body. The tests cover lost responses after a successful create, edits made between preview and apply, a key that moves to another account, and private image URLs. Every write tool has also run live against a real ad account: create, update, enable, pause and archive for campaigns, ad groups and ads. The redacted transcript is linked in the review reply.

I also changed the rules files (a tool section and two orchestration patterns), the README and config.yaml.example. I didn't regenerate reference/tools.mdx in the docs repo.

@kLOsk

kLOsk commented Nov 8, 2026

Copy link
Copy Markdown
Owner

Thanks for this, Illia. ChatGPT Ads is welcome in the open-source package. Three decisions first, then the review.

How we'd like to land it

  1. Please move the shared changes into their own small PR: the blocked-operations re-check in confirm_and_apply, the approval display change (reworked as below) and safety/outcomes.py from Microsoft Advertising: reads, reports and campaign management #130. We'll merge that first. ChatGPT Ads: reads, campaign management and conversion diagnostics #127 and Microsoft Advertising: reads, reports and campaign management #130 can then both sit on top of it and land independently.
  2. ChatGPT Ads goes in as an open-source, community-maintained toolset. It won't be offered on AdLoop Cloud for now. If it ever is, we'll take over maintenance of the module.
  3. On our side we'll add a startup step that hides a platform's tools when it isn't configured, so local users without ChatGPT Ads don't get 17 extra tools. Nothing needed from you there.

Must fix

  • display_fields hides every top-level before, but Tag Manager update plans keep the values the user approves against under before (gtm/write.py:499, 627, 776). GTM tag, trigger and variable previews would lose them in the dialog and on the card. Please put ChatGPT's request payload and snapshot into ChangePlan.apply_only_payload (never shown or logged) and drop the name-based hiding in both approval.py and approval_card.html.
  • Switching a campaign from a lifetime to a daily budget compares the old lifetime total with the new daily amount (chatgpt_ads/write.py:610-617). Lifetime 500 → daily 100 is roughly 6x the daily spend, yet gets no double confirmation, and the warning points the wrong way. Please compare daily equivalents.

Should fix

  • Preflight finds the entity by scanning the whole account (find_entity, write.py:185) at draft, dry run and apply. The API has GET /campaigns|ad_groups|ads/{id}: please fetch by id in preflight and keep the scan for parent names at draft only.
  • Image ads: preflight checks the image with our fetcher, then /upload sends the URL and OpenAI fetches it again, so the uploaded bytes can differ from the checked ones. Please pin a sha256 at draft, re-check it, and upload the bytes we fetched.
  • A request error during a POST is reported as "nothing is known to have changed" (client.py:262). After a timeout the outcome is unknown: please say so, and have preflight recognise "already at the planned value", so a retry isn't refused as "changed since the draft".
  • _require_unchanged compares whole creative, targeting and bidding_config objects. Please compare only the subfields the plan sends; a response-only field such as creative.image_url would otherwise break every dry run if it ever changes between reads.
  • custom_audience_bid_multipliers: [] is sent on update, but the docs list that field for create requests only.
  • Skip BLOCKED plans in _dialog_applies before asking the user, and write an audit entry for BLOCKED refusals.
  • _guard, _store and the blocked-operation refusal belong in safety/guards.py rather than a third copy next to Reddit and GTM; reuse the Reddit client helpers where they are identical; drop the dead code (_Refusal, is_chatgpt_plan, _IMAGE_TYPES).
  • Context hints in the ad group read have no compact mode or cap (up to 2,000 per ad group).
  • Small: chatgpt_attribution_check also needs GA4 but is only tagged chatgpt_ads, and the confirm_and_apply docstring doesn't mention ChatGPT plans.

Before merge
The tests drive the real gate against a fake that encodes your reading of the API. Could you attach a redacted transcript of each write operation against a real ad account (create a paused campaign, ad group and ad; update; pause, enable, archive)? And which create endpoints actually honor Idempotency-Key? The docs say "supported create endpoints", while the tool descriptions promise no duplicates unconditionally.

illia-sapryga added a commit to illia-sapryga/adloop that referenced this pull request Nov 8, 2026
Microsoft Advertising (Bing) through the REST API v13: Entra sign-in
(authorization code + PKCE, device code as fallback), account, campaign,
ad group, keyword, ad and extension reads, performance, search-term and
location reports, the conversion setup audit, and campaign management
behind the draft -> preview -> confirm_and_apply gate: budgets, status,
campaigns and targeting, ad groups, keywords and bids, negatives and shared
lists, RSAs, sitelinks, callouts, snippets, call and image extensions, and
removal.

Rebased onto the shared write-gate change instead of kLOsk#127.
@illia-sapryga

illia-sapryga commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks, Daniel. All done; the branch is force-pushed.

Landing

Must fix

  • Payloads.
    • The request body, snapshot, account selector, status action and image pin are now in ChangePlan.apply_only_payload. The name-based hiding is gone from approval.py and the card.
    • changes keeps only what the user approves. Empty values are dropped, and updates show before → after under display, which the new dialog puts on its summary line.
    • If a plan store drops the payload, the apply refuses and sends nothing.
    • Tests cover the preview, the dialog, the dry run, the audit log and the apply.
  • Lifetime → daily budget. Both sides are compared as daily amounts. Lifetime 500 over a 31-day flight → daily 100 is about 6.2x. It gets the double confirmation, and the warning says which way it goes. Shortening a lifetime flight also counts as a raise.

Should fix

  • Lookups.
    • Preflight and apply read the target with GET /campaigns|ad_groups|ads/{id}.
    • Drafts still scan the account for parent names, since ad groups and ads carry no parent id. They now read the entity itself by id first.
    • Live, a campaign created a moment earlier was missing from GET /campaigns, and drafting its ad group answered "not found" (0d2b24b). A scan that misses now only leaves the parent names unknown, and the preview says so.
  • Images. The draft pins the sha256 of the bytes it fetched through image_fetch. The dry run and apply re-read them and refuse bytes that changed, and the apply uploads exactly those bytes as multipart.
  • Lost answers.
    • A timeout, dropped connection or 5xx on a write raises ChatGPTAdsOutcomeUnknown ("whether it changed anything is unknown").
    • A request that never connected says nothing changed.
    • Preflight counts a field already at the planned value as done, so a retry sends nothing, or only what's still missing.
    • Creates keep the plan and resend the same key and body (see Idempotency-Key below).
  • _require_unchanged compares only what the plan sends: budget limits, the sent bidding_config keys, the sent creative keys, and targeting in its writable form.
  • custom_audience_bid_multipliers: []. OpenAI's bidding guide sends it on update to clear multipliers when an ad group moves to automatic bidding. It's now sent only in that case and left out otherwise.
  • Shared helpers.
    • _guard / _store now use the guards.py helpers.
    • _Refusal, is_chatgpt_plan and _IMAGE_TYPES are deleted.
    • The micros conversions are Reddit's from_micro / to_micro. Nothing else in the two clients was identical.
  • Context hints. Previews list 20 and count the rest. get_chatgpt_ad_groups lists 50 per ad group with context_hint_count, and has a compact mode.
  • Small items.
    • chatgpt_attribution_check is tagged chatgpt_ads and ga4. The one-tag test allows the tools listed in a new CROSS_PLATFORM_TOOLS in server.py. Happy to do it another way if your startup filter wants something else.
    • The confirm_and_apply docstring describes ChatGPT plans.

Before merge

  • Live transcript (redacted): https://gist.github.com/illia-sapryga/6071e4aed3b7a660ebbbe01a9c0beab9
    • It covers a paused campaign, ad group and ad, each create replayed with the same Idempotency-Key, an update of each, and the ad enabled (its campaign stayed paused) and paused again. All three were then archived.
    • Every step goes draft → dry run → apply and lists each HTTP request with its body and answer.
  • One more gap the run found. OpenAI refuses daily budgets under 25 ("Daily campaign budget must be at least 25000000 micros"), although the spec's minimum is 1,000,000. The draft and the update now refuse them (91dfad9).
  • Idempotency-Key.
    • The OpenAPI spec documents the header on POST /campaigns, /ad_groups and /ads: "If a matching prior create is visible, the existing object is returned instead of creating a duplicate." /upload has none.
    • Live, all three replays returned the original object.
    • The tool descriptions, rules and README now claim only that. After a lost create, the error tells the user to check the account and wait a minute before confirming again.

Full suite: 2162 passed, 2 skipped.

kLOsk pushed a commit that referenced this pull request Nov 9, 2026
* Write gate: re-check blocked operations at apply, retire partial applies, shared draft helpers

- confirm_and_apply refuses a plan whose operation was blocked after the
  draft (BLOCKED, dry run and apply alike), logs the refusal, and keeps the
  plan pending. The confirmation dialog is not shown for such a plan.
- safety/outcomes.py: PartialApplyError. An applier raises it when part of
  a plan reached the account or a create's outcome is unknown;
  confirm_and_apply retires the plan and answers PARTIAL_APPLY or
  UNKNOWN_OUTCOME with what was created.
- safety/guards.py: blocked_operation_error, store_draft and
  blocked_plan_refusal, used by Reddit and Tag Manager instead of their
  own copies.
- Approval card: Microsoft Advertising and ChatGPT Ads labels, as in the
  confirmation dialog.

* confirm_and_apply: the docstring names BLOCKED, PARTIAL_APPLY and UNKNOWN_OUTCOME

* Microsoft Advertising: reads, reports and campaign management

Microsoft Advertising (Bing) through the REST API v13: Entra sign-in
(authorization code + PKCE, device code as fallback), account, campaign,
ad group, keyword, ad and extension reads, performance, search-term and
location reports, the conversion setup audit, and campaign management
behind the draft -> preview -> confirm_and_apply gate: budgets, status,
campaigns and targeting, ad groups, keywords and bids, negatives and shared
lists, RSAs, sitelinks, callouts, snippets, call and image extensions, and
removal.

Rebased onto the shared write-gate change instead of #127.

* Microsoft Advertising: review fixes

- Broad match only with conversion-based bidding (MaxConversions,
  MaxConversionValue, TargetCpa, TargetRoas); Maximize Clicks no longer
  counts. Moving to any other strategy is refused while active broad
  keywords exist, at the draft and again at the dry run and apply; the
  keyword read's insight follows the same rule.
- remove_microsoft_entity reads a campaign criterion at the draft, names
  its type and target, and refuses removing the last targeted location
  (the campaign would serve everywhere), again at the dry run.
- Request bodies, snapshots and ids move from plan.changes to
  ChangePlan.apply_only_payload; preflight and apply read
  plan.apply_payload(). Drafts use the shared store_draft and
  blocked_operation_error.
- MaxConversions without an active conversion goal is refused at the draft
  and the dry run (Microsoft refused such a create live with
  ConversionGoalCriteriaNotMetForBiddingScheme) and names the alternatives.
- Throttling: codes 117 and 207 (and HTTP 429) raise at once with
  retry_after 60 instead of retrying after 5 s.
- Plans store their campaign and ad group, so dry runs and applies read
  three lists instead of every ad group of the account; campaign reads ask
  once per campaign for its targeting (CriterionType Targets, with the
  per-type queries as fallback).
- Reads: criterion type Radius, Audience Network inherited from the
  campaign when an ad group has no adjustment of its own; new campaigns
  warn about the Audience Network default.
- Sign-in binds a free localhost port (Entra ignores the port), keeps
  listening until the state matches, and writes the token through an
  O_EXCL temp file. A bare 105 after a working sign-in is explained:
  the identity is not a user of the account, often because its users
  sign in with Google, which is not supported yet.
- languages=['All'] is refused; target_cpa and max_cpc on a strategy that
  cannot use them are refused instead of dropped.
- Dead code removed; the schedule limit is 7 windows a day throughout.

* Microsoft Advertising: split manage.py into per-area handlers

manage.py (2.4k lines, two parallel ~20-branch dispatchers) becomes the
package microsoft_ads/manage: _common (constants, request and plan
helpers, bidding, Step and Handler), campaigns, targeting, ad_groups,
negatives, ads, extensions and removal. Each area drafts its plans and
registers its operations in HANDLERS (operation -> dry-run check, apply);
the package merges them into one table that preflight and apply_plan look
up. The code of every check and apply moved unchanged; imports from
adloop.microsoft_ads.manage keep working.

* Microsoft Advertising: campaign reads ask for their criteria types in one query

Found in the live run: CampaignCriterions/QueryByIds refuses the "Targets"
shorthand (4503 CampaignCriterionTypeInvalid, "Invalid API Campaign
Criterion Type : Targets on API tier"), which adds, updates and deletes
accept. The types joined by commas ("Location,LocationIntent,Radius") are
accepted, so get_microsoft_campaigns now makes one criterion call per
campaign; the per-type fallback stays for a refusal. The test fakes refuse
"Targets" in a query, as Microsoft does.
@kLOsk

kLOsk commented Nov 9, 2026

Copy link
Copy Markdown
Owner

Thanks, Illia. #134 and #130 are merged, so this one now conflicts with main where both platforms plug in: TOOLSETS, confirm_and_apply, auth, config, the rules, the README and the tests. Could you rebase it onto main?

While you're at it, please add chatgpt_ads to _PLATFORM_SETUP in server.py, next to Reddit and Microsoft, so a local server hides the ChatGPT Ads tools when no key is set up. The check needs to cover chatgpt_ads.api_key, chatgpt_ads.accounts and OPENAI_ADS_API_KEY.

The review fixes look right from what I've read so far. I'll do a last pass after the rebase, then merge.

ChatGPT Ads (OpenAI Ads Manager) through the OpenAI Advertiser API: account,
campaign, ad group and ad reads, performance and conversions, the conversion
setup audit, the GA4 attribution check, and paused-creating campaign, ad group
and ad writes behind the draft -> preview -> confirm_and_apply gate.

Rebased onto the shared write-gate change; the blocked-operation re-check and
the approval display now come from there.
- Payloads: the request, the snapshot, the account selector and the image
  pin move to ChangePlan.apply_only_payload. changes keep only what a person
  approves (empty values dropped; updates show before/after under
  "display"). Drafts use safety.guards.store_draft and
  blocked_operation_error; _Refusal, _guard, _store copies,
  is_chatgpt_plan and _IMAGE_TYPES are gone. A plan store that drops the
  payload makes the apply refuse instead of sending an empty request.
- Budgets: changes are judged on daily equivalents. Lifetime 500 over 31
  days to daily 100 is about 6.2x the daily spend: double confirmation and
  a warning that says so. Shortening a lifetime flight counts as a raise.
- The dry run and the apply read the target (or the parent of a create)
  with GET /campaigns|ad_groups|ads/{id}; the account scan stays for the
  parent names a draft shows.
- Images: the draft pins the sha256 of the bytes fetched through the
  public-only fetcher (or read from the file); the dry run and the apply
  read them again and refuse different bytes, and the apply uploads exactly
  the checked bytes as multipart, never the URL.
- Lost answers: a timeout, dropped connection or 5xx on a write raises
  ChatGPTAdsOutcomeUnknown ("whether it changed anything is unknown").
  Reads and requests that never connected say nothing changed. The dry
  run recognises fields already at the planned value, so a retry sends
  nothing (or only what is still missing); a create retry resends the same
  Idempotency-Key and body.
- Change detection compares only the subfields the plan sends (budget
  limits, sent bidding_config keys with normalised multipliers, sent
  creative keys, targeting in its writable form); response-only fields
  such as creative.image_url no longer block a dry run.
- Audience multipliers: [] is sent only to clear existing ones when an ad
  group moves to automatic bidding (as OpenAI's bidding guide does);
  otherwise the field is left out.
- Micros conversions are the Reddit client's from_micro / to_micro.
- Context hints: previews list 20 and count the rest; get_chatgpt_ad_groups
  lists 50 per ad group with context_hint_count and has a compact mode.
- chatgpt_attribution_check carries the ga4 tag too (CROSS_PLATFORM_TOOLS
  in server.py, the one toolset tag rule allows it); the confirm_and_apply
  docstring describes ChatGPT plans.
- A ChatGPT-only hosted-provider health test.
- Rules, README and tool descriptions claim what OpenAI documents about
  Idempotency-Key: the existing object comes back when a matching prior
  create is visible.
Found in the first live write: OpenAI's spec allows daily_spend_limit_micros
from 1,000,000, but POST /campaigns answers 400 "Daily campaign budget must
be at least 25000000 micros". The draft (and the update) now refuse it, so a
dry run cannot pass a create the apply cannot make.
…y hides parent names

Found in the live run: a campaign created a moment earlier was missing from
GET /campaigns, so drafting its ad group answered "not found". find_entity
now reads the entity by id and scans the lists only for its parents' names;
when the scan misses, the draft says what it could not check. An ad group
update, which needs its campaign's bidding, asks to retry in a few minutes.
Adds chatgpt_ads to _PLATFORM_SETUP, next to Reddit and Microsoft
Advertising: without a key in chatgpt_ads.api_key, chatgpt_ads.accounts or
OPENAI_ADS_API_KEY, the local server keeps the ChatGPT Ads tools out of
tools/list, and health_check names the key to add under hidden_toolsets.

- The check looks where the client looks for a key (local_api_key), so a
  blank OPENAI_ADS_API_KEY is no key. health_check's own check uses it too.
- chatgpt_attribution_check carries ga4 as well. A hidden toolset hides
  every tool that carries it, so the cross-check leaves with the ChatGPT
  tools and the GA4 tools stay.
- The hiding tests clear OPENAI_ADS_API_KEY, so a key in the developer's
  environment doesn't change what they see.
As for Microsoft Advertising: "chatgpt_ads": "not_configured" appears only
when the caller is offered the ChatGPT Ads tools. A hosted runtime that
doesn't offer them no longer sends its users to set them up, and a local
server that hid them says so once, under hidden_toolsets. Hosted, the key
is read only where the runtime offers the tools, so a workspace without
ChatGPT Ads gets no ChatGPT Ads error.
Cloud does not offer the community-contributed ChatGPT Ads tools, so the
sync drops their section and the two ChatGPT Ads orchestration patterns,
and takes ChatGPT Ads out of the health_check line, as it does for
Microsoft Advertising. Without this the sync stopped on the changed
health_check line, and the section's config instructions have no Cloud
rewrite. The Cloud files generated from these rules are the same as from
main's.
@illia-sapryga

Copy link
Copy Markdown
Contributor Author

Thanks, Daniel. Rebased onto main (0.41.2). ChatGPT Ads now sits next to Microsoft in TOOLSETS, confirm_and_apply, auth, config, the rules, the README and the tests. The #134 commits are gone; the module and its tests are unchanged.

  • 770db9c: chatgpt_ads is in _PLATFORM_SETUP. A local server hides the tools unless there's a key in chatgpt_ads.api_key, chatgpt_ads.accounts or OPENAI_ADS_API_KEY, using the same lookup as the client, so a blank env var counts as no key. chatgpt_attribution_check is hidden with them; the GA4 tools stay.
  • Following what you did for Microsoft: 0bf2884 makes health_check report chatgpt_ads only where its tools are offered, and 448b718 makes the plugin sync drop the ChatGPT sections, so the Cloud files come out the same as from main. Both are separate commits, so either can be dropped.

The full suite passes locally (2,423 tests), and the description is updated.

@kLOsk

kLOsk commented Nov 12, 2026

Copy link
Copy Markdown
Owner

Thanks, Illia. The rebase is clean against main (0.41.4), and the earlier points check out in the code: the apply-only payload, the image checksum, the unknown-outcome handling, the account pin at apply, and the tools hidden without a key. Two things from the last pass, one of them before merge.

Must fix

  • _daily_equivalent (chatgpt_ads/write.py) spreads a lifetime budget over the whole flight, from start_time, even when the start is in the past. A budget is spent in the days that are left, so days that have passed thin out the per-day share and the max_daily_budget check passes amounts it should refuse:

    • draft_chatgpt_campaign with start_date a year ago, end_date next week and lifetime_budget 3500 comes out at about 9 a day instead of 500.
    • A campaign 60 days into a 62-day flight: raising the lifetime budget from 620 to 3000 counts as about 48 a day, while up to 2400 can be spent in the last two days.

    Please count from now when the flight has already started (max(start, now)), in the draft, the update and the preflight, which all go through this function. Reddit got the same change in 0.41.3 (_days_between in reddit/write.py). Tests for a past start on create and for a late-flight raise would pin it.

Should fix

  • campaign_id, ad_group_id, ad_id and entity_id go into the request path as given (_get, the status actions, the update POST). An id with /, .. or ? in it turns into a different path than the preview describes. Each key only reaches its own account, so the reach is small, but please refuse anything that isn't a plain id before the first request, as Reddit does with _ENTITY_ID.

#121 and #122 are merged, thanks for both. They touch server.py, the rules and ads/write.py, so this one may need another small rebase. With the budget fix in, I'll merge.

This branch has not been deployed

No deployments
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