{"slug": "claude-api-guard-a-ci-check-that-catches-claude-openai-sdk-breaking-changes", "title": "Claude-API-guard, a CI check that catches Claude/OpenAI SDK breaking changes", "summary": "MarkMoneyMan released Claude-API-guard, a GitHub Action that scans codebases for breaking changes in Anthropic's Claude and OpenAI SDKs, auto-fixing mechanical issues and updating its rules from official release notes via an LLM. The tool requires no API key for scanning, fails CI jobs only on HIGH-severity findings by default, and is licensed under Business Source License 1.1 until 2030-09-01, when it converts to MIT.", "body_md": "A GitHub Action that scans your codebase for usage of the Claude/Anthropic and OpenAI APIs that's broken, or about to break, because of a known, dated API change — and auto-fixes the mechanical ones. It keeps its own rule set current by reading each provider's official release notes on a schedule and extracting new breaking changes with an LLM, so it doesn't go stale the way a hand-maintained list would.\n\nLLM provider SDKs change fast, and \"it worked last month\" is not the same as \"it still works.\" A sampling parameter gets removed, an HTTP client gets swapped, a response shape gets renamed — and the first anyone hears about it is a production error, not a changelog. This tool is meant to be the thing that catches that in CI, before it ships.\n\n**Where it's strongest right now, and where it's headed:** claude-api-guard\nstarted as, and is still deepest on, Anthropic's Claude API — every rule is\nvalidated against real downstream code (not just written and assumed\ncorrect; see the engineering log below for the actual false positives found\nand fixed), and its rule set updates itself from Anthropic's live release\nnotes. OpenAI support followed the same bar: hand-extracted from OpenAI's\nown changelog and migration guides, then fully triaged against a large real\ncodebase (litellm) until every finding checked out. The plan from here is\nto keep expanding provider coverage outward from that same foundation —\nthis is meant to grow into a broader \"breaking-change guard for every API\nyour project depends on\" tool, not stay a single-provider niche script. The\n`\"provider\"`\n\nfield already built into every rule, and the per-provider\n`PROVIDERS`\n\nconfig in `sync_rules.py`\n\n, exist specifically so adding the\nnext provider is a matter of writing its rules and its changelog parser,\nnot restructuring the tool.\n\n**No API key needed to use it.** Scanning your code costs nothing and calls\nno LLM at runtime — every rule ships pre-baked in this repo. An Anthropic\nAPI key is only used on *this* repo's own maintenance side, to power the\nweekly job that reads provider release notes and proposes new rules (every\nproposed rule still goes through a human-reviewed PR before it's live — see\n\"Rule sync\" below).\n\nAdd this to a workflow file in the repo you want to protect (e.g.\n`.github/workflows/claude-api-guard.yml`\n\n):\n\n```\nname: claude-api-guard check\non:\n  pull_request:\n  push:\n    branches: [main]\n\njobs:\n  check:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: MarkMoneyMan/Claude-api-goat@master\n        with:\n          path: .\n          fail-on: HIGH        # MEDIUM/LOW findings are reported but won't fail the job\n          # scan-js: \"true\"    # also scan JS/TS files for Anthropic SDK usage\n```\n\nThat's it — no secrets, no config file, no signup. It fails the job only on\nHIGH-severity findings by default, so a heads-up doesn't block a merge the\nway a real break should. See `examples/consumer-workflows/`\n\nfor a weekly\nauto-fix variant that opens a PR for the mechanical fixes on its own.\n\nCovers Python (Anthropic + OpenAI SDKs) and, as a first pass, JS/TS (Anthropic SDK only so far) — see \"Known limitations\" below for exactly what is and isn't covered yet.\n\nBusiness Source License 1.1 (see `LICENSE`\n\n) — free to read, run, self-host,\nmodify, and build on for your own use, including commercial use. The one\nthing it reserves is standing up claude-api-guard itself as a competing\npaid hosted service before 2030-09-01, at which point it converts\nautomatically to the MIT License. This is not a restriction on *using* the\ntool to protect your own project — that's unrestricted from day one.\n\nEverything below is the detailed, dated record of how this was actually built and validated — real bugs found, real repos tested against, real CI runs, kept as running documentation rather than cleaned up after the fact. It's here for anyone who wants to verify the claims above rather than take them on faith.\n\n-\n— v1, regex over the whole file. Fast to build, but tested against 5 real public repos (anthropic-cookbook, anthropic-sdk-python, llm, aider, OpenHands, litellm) and produced`scan.py`\n\n**1,576 findings total**, the vast majority false positives: comments, docstrings, string literals, and — in multi-provider codebases like litellm — other vendors' API calls that happened to share a method name with Anthropic's. -\n— v2, walks the real Python syntax tree instead of matching text. Started with hand-coded checks for the 9 hand-written rules only; as of 2026-08-27 it also runs a generic engine (`ast_scan.py`\n\n`generic_scan()`\n\n) that turns any rule from`rules.py`\n\n— including ones`extract_rules.py`\n\ngenerates automatically — into an AST-level check, without hand-coding logic per rule. See \"Rules\" and step 2 below for how that engine earned its noise budget the hard way. -\n— JS/TS sibling, added 2026-08-27. Node +`js_scanner/`\n\n`@babel/parser`\n\ninstead of Python's`ast`\n\nmodule (simpler than getting a tree-sitter grammar built in this environment; same \"walk the real syntax tree\" idea). See \"JS/TS support\" below.\n\n`rules.py`\n\nholds the current rule set: 10 hand-extracted from Anthropic's\nlive release notes (as of 2026-08-27), plus whatever `sync_rules.py`\n\nhas\nappended automatically since. `extract_rules.py`\n\nis the LLM-extraction\nstep itself (changelog text in, structured rules out); `sync_rules.py`\n\nis what actually runs it unattended — see \"Rule sync\" below. Both need\nan `ANTHROPIC_API_KEY`\n\nto run for real.\n\n- Only catches calls written in a fairly direct style. Code that builds\nthe call via\n`**kwargs`\n\nsplatting or heavy indirection won't be seen (confirmed: this is why`aider`\n\nshows 0 findings — it talks to Claude through`litellm`\n\n, not the Anthropic SDK directly, so there's nothing for this tool to see there yet). - Structurally blind to raw-HTTP integrations (a hand-built JSON body\nPOSTed straight to\n`api.anthropic.com`\n\n, no official SDK in the call path at all) — every rule matches an SDK call*shape*, so there's nothing for the AST walk to find. Confirmed twice now, not just theorized: litellm's own Anthropic integration (HTTP-level, not SDK-level) and a legacy Node.js prototype inside`oddsscanner`\n\n(`server.js`\n\n) both produce 0 findings for this reason, not because they're actually safe. A future rule format that also matches literal header/param strings in a raw request body could close part of this, but nothing like that exists yet. - Python only for detection; JS/TS added as a first pass (see below), no Go/other-language support.\n- Rule\n*sync*runs on a schedule now (`sync_rules.py`\n\n+`update-rules.yml`\n\n), but every extracted rule still goes through a PR a human reviews before it's live — deliberately not fully unattended. - OpenAI support (\n`rules_openai.py`\n\n) is new, small (4 rules), hand-seeded rather than auto-extracted, and Python-only — not yet wired into rule sync, CI self-check, or the JS/TS scanner. See \"Multi-provider support\" below for exactly what's been tested and what hasn't.\n\nReal code: `/Users/markus/Desktop/oddsscanner`\n\n, re-run directly (not\nthrough CI — that repo has no `.git`\n\nyet) against the full current rule\nset, Python and JS both, once the rule sync work above made the rule\ncount grow well past the original 6.\n\nclean. Confirmed by reading the actual call site, not just trusting the scanner:`app.py`\n\n(the live backend —`start.sh`\n\n/`start.bat`\n\nboth run this, port 5000):`anthropic.Anthropic(...)`\n\n,`client.messages.create(model=\"claude-sonnet-4-6\", max_tokens=..., system=[...], messages=[...])`\n\n— no temperature/top_p/top_k, no`.with_raw_response`\n\n, no beta headers, no`AnthropicBedrock`\n\n. SDK is pinned to`anthropic==0.28.0`\n\n, well below v1.0, so the SDK-v1.0 rules correctly don't fire yet — this is a true negative, not a blind spot.also 0 findings, but for a reason worth stating plainly rather than taking credit for: this code never calls the Anthropic SDK at all. It hand-builds a JSON body and POSTs it to`server.js`\n\n+`index.html`\n\n(an older Node.js prototype, both dated well before`app.py`\n\nand`static/index.html`\n\n, and not what`start.sh`\n\n/`start.bat`\n\nactually launch):`https://api.anthropic.com/v1/messages`\n\nwith Node's raw`https`\n\nmodule. Every rule in`rules_js.js`\n\nmatches SDK call*shapes*(`.messages.create(...)`\n\n,`.beta.files`\n\n, ...), so there is structurally nothing here for it to match — the same class of blind spot already documented for litellm's own Anthropic integration, now confirmed in a second, real, personally-used codebase rather than just a public one. Concretely: this dead path has a hardcoded, dated model snapshot (`claude-sonnet-4-20250514`\n\n) that a raw-HTTP-aware rule set would reasonably flag someday — worth knowing about even though it's not live traffic today.\n\nPlus the 6 public repos above for false-positive testing.\n\n-\n~~Handle~~—`**kwargs`\n\n-style calls**done.** Built and validated (`example_project/bot.py`\n\nhas a synthetic test case for it), but it changed**zero** findings across the 6 real repos. Turned out litellm doesn't call the official`anthropic`\n\nSDK at all in its own Anthropic integration — it reimplements the API at the HTTP level (`litellm/llms/anthropic/...`\n\n), so there was never an SDK call site there to find. Same root cause explains aider's 0 findings: it talks to Claude through litellm, never through`anthropic.Anthropic()`\n\ndirectly. Correcting the earlier claim that kwargs-handling would \"unlock\" either of them — it doesn't; that's a structurally different, bigger problem (would need to understand each abstraction layer's own API, not just the official SDK's). -\n~~Automate~~—`extract_rules.py`\n\n**ran for real on 2026-08-27**, first real (small) cost in this project. Fed it Anthropic's actual release notes (last ~8 weeks,`pipeline_runs/2026-08-27_changelog_input.txt`\n\n) with a real`ANTHROPIC_API_KEY`\n\n. Result:`pipeline_runs/2026-08-27_extracted_rules.json`\n\n— 14 rules extracted automatically. It correctly found all 6 breaking changes that had been hand-written into`rules.py`\n\nearlier (SDK v1.0 sampling params, legacy Text Completions removal, Opus 5 xhigh/max thinking error, Opus 4.7 fast-mode removal, Opus 4.1 retirement, experimental prompt-tools retirement)**plus 8 more that hand-extraction had missed**: an httpx→httpx2 migration in SDK v1.0,`compaction_control`\n\nremoval, an async`.with_raw_response`\n\nbehavior change,`AnthropicBedrock`\n\n's dropped default AWS region, the Python 3.10 floor, a`client.beta.files`\n\n/`client.beta.skills`\n\nshape change, a Managed Agents header behavior change, and a computer-use toolset shape change. One real bug found and fixed running this live: the first version hardcoded`max_tokens=4096`\n\n, which silently truncated the JSON output mid-string on a real-size changelog batch and threw a parse error — fixed by raising the limit and by making`extract()`\n\nraise a clear error on`stop_reason == \"max_tokens\"`\n\ninstead of failing on a cryptic JSON error.**Known gap at the time, stated plainly:** these 14 auto-extracted rules used the v1`rules.py`\n\nschema (regex`pattern`\n\nfield) that`scan.py`\n\nreads —`ast_scan.py`\n\n(the good, low-noise v2 scanner) didn't read`rules.py`\n\nat all; every check in it was hand-coded per rule type. Closed in step 3 below. -\n~~Teach~~—`ast_scan.py`\n\nto consume auto-extracted rules**done, and it broke on the first real run, which is exactly why \"run it for real\" beats \"looks right on paper.\"** Added`generic_scan()`\n\n: for each of the 8 new (non-hand-coded) rules, it regex-matches the rule's`pattern`\n\nagainst the unparsed source of individual real AST nodes (`Call`\n\n,`Import`\n\n,`Assign`\n\n, ...) — never the whole file, so it structurally can't match a comment or a docstring the way v1 did. First run against the same 6 repos:**litellm alone produced 1,720 findings**, almost all from one rule (`python-sdk-v1-httpx-to-httpx2`\n\n, 1,604 hits) and a second (`python-sdk-v1-async-with-raw-response`\n\n, 114 hits). Both were the*same class*of bug as the very first`chat.completions.create`\n\ncollision, just recurring at the pattern level instead of the file-context level:`httpx`\n\nis a generic HTTP library. litellm imports it ~40 times for its own multi-provider handling and — confirmed by grep — never imports the actual`anthropic`\n\npackage in any of them. The pattern alone can't tell \"this httpx client feeds the Anthropic SDK\" apart from \"this httpx client does literally anything else.\"`.with_raw_response`\n\nisn't Anthropic-specific either — it's a shared naming convention across every Stainless-generated SDK, and OpenAI's is one too. litellm's Azure/OpenAI calls (`azure_client.chat.completions.with_raw_response.create(...)`\n\n) matched it directly. Separately, the real breaking change only affects the*async*client, and the pattern had no async awareness at all — its single false-positive hit in`anthropic-sdk-python`\n\nitself was a**sync** test correctly calling`response.parse()`\n\nwith no`await`\n\n, not broken code.\n\nFix, in both cases: not a wider or narrower regex, but one real structural precondition per rule (\n\n`GENERIC_EXTRA_CONDITIONS`\n\nin`ast_scan.py`\n\n) — \"this file actually imports`anthropic`\n\n\" (checked correctly for*absolute*imports only; a second bug surfaced here too, since litellm's own`from ...anthropic.chat.transformation import X`\n\nis a*relative*import of its own same-named submodule and initially tripped the naive version of this check) and \"this call site sits inside an`async def`\n\n.\" After both fixes, same 6 repos:**litellm 1,720 → 2, aider 11 → 0, anthropic-cookbook's 36 remaining findings all check out on inspection**(real`client.beta.files`\n\n/`client.beta.skills`\n\ncalls that will genuinely need updating). One repo didn't clean up:`anthropic-sdk-python`\n\nstill shows ~1,478, because it's not a fair test bed for these particular rules — it*is*the SDK, so its own source and test suite naturally define and exercise the exact strings these rules look for (e.g. the one`memory-list`\n\nhit inspected was the SDK's own source*defining*the`MANAGED_AGENTS_BETA`\n\nconstant). That's a limitation of the test setup, not a scanner bug — but it's honest to say the generic engine has only been proven clean against real*downstream consumer*code, not against a library that mirrors its own rules back at itself.**Update, found building the JS/TS scanner below:** that ~1,478 number was itself inflated by a real bug, not just the self-referential-repo problem —`generic_scan`\n\n's candidate node types overlap (a`Call`\n\nis a child of the`Assign`\n\nthat captures its result, e.g.`client = AnthropicBedrock(...)`\n\n), so the same real match got reported twice, once per node. Confirmed: 427 of the 1,478 were exact-duplicate`(file, line, rule_id)`\n\ntriples. Fixed with a`dedupe_findings()`\n\npass that also prefers the more informative duplicate (a model-scoped rule can only confirm the model on the`Call`\n\nnode itself, never on the wrapping`Assign`\n\n— naive dedup could keep the less-informative \"unconfirmed\" copy). Real count for`anthropic-sdk-python`\n\n:**1,051**, still mostly the self-referential-repo effect, not noise. -\n~~Multi-language support (start with JS/TS)~~—**done as a first pass**, see \"JS/TS support\" below. -\n~~Auto-fix: generate the actual code patch~~—**done for a small, deliberately mechanical subset.** See \"Auto-fix\" below. (\"...and open a PR\" is now just the`git`\n\n/`gh`\n\nmechanics on top of a real patch — not attempted against a real third-party repo without being asked to.) -\n~~Package as a CI Action~~—**done**, and it found a real bug on its first real Actions run. See \"CI / GitHub Action\" below. -\n~~Automate the rule-extraction step end-to-end (not just \"ran once by hand\")~~—**done.**`sync_rules.py`\n\n+`.github/workflows/update-rules.yml`\n\nrun this on a schedule now instead of a human copy-pasting changelog text into a file. See \"Rule sync\" below. -\n~~Package~~—`autofix.py`\n\nas something installable, instead of`autofix-weekly.yml`\n\nchecking out this whole repo for one file**done.**`pyproject.toml`\n\n+ two console-script entry points; see \"Packaging\" under \"Auto-fix\" below for what that did and didn't fix.\n\n`autofix.py`\n\ngenerates real source patches — not suggestions in a report —\nfor **5 of the ~19 rules**, chosen because the fix is a pure deletion or a\n1:1 string swap with no judgment call attached (no \"which model should\nthis migrate to,\" no \"how should this system-prompt instruction be\nphrased,\" no \"which effort level is right here\"). Everything else stays\ndetection-only on purpose: a wrong regex was already the first act of this\nproject (`scan.py`\n\n); a wrong auto-fix rewrites someone's actual code, which\nis a worse failure than not fixing it. See the module docstring in\n`autofix.py`\n\nfor the full list and the reasoning per rule, fixed and\nnot-fixed alike.\n\nEvery patch goes through one hard gate before it's ever written: the\npatched file must still parse (`ast.parse`\n\n) or the patch is refused and\nlogged, never applied. That gate mattered for real, immediately — first\nrun against a real repo (a copy of `anthropic-cookbook`\n\n) hit a genuine bug\nin the edit engine: deleting the *last* keyword argument in a call only\nscanned backward through same-line whitespace looking for the separating\ncomma, so when the previous argument was on an earlier line (the common\none-arg-per-line style), it never found that comma and left the deleted\nargument's own trailing comma orphaned on its own line — invalid syntax.\nThe parse gate caught it before anything was written; the practical effect\nwas just a silently-skipped fix, not corrupted code. Fixed by mirroring\nthe already-correct forward-scanning logic (cross one newline + its\nindentation, not just spaces/tabs on the same line) and re-verified.\n\n**Validated:** `example_project/autofix_test.py`\n\nhas one call per\nauto-fixable rule plus two calls that must NOT be touched (a deprecated\nmodel string, a manual thinking budget) — confirmed after fixing: the 5\nfixable ones are gone, the 2 judgment-call ones are untouched, the file\nstill parses. Then for real: ran `--write`\n\nagainst a full copy of\n`anthropic-cookbook`\n\n. Result: **20 edits across 9 real files**, every\npatched file still parses, and rescanning afterward shows only the 4\n`assistant-prefill-removed`\n\nfindings left — exactly the ones this tool\nwas never supposed to touch. Known gap: the fixer only edits a direct\nkeyword argument on the call site itself, not one assembled in a `**kwargs`\n\ndict elsewhere (the same splat-resolution limitation `ast_scan.py`\n\n's\n*detection* side already handles for reading, but hasn't been extended to\nfor writing) — one real `temperature=`\n\nfinding in cookbook was left\nun-autofixed for exactly this reason, correctly, rather than attempting an\nedit somewhere else in the file it wasn't confident about.\n\nAlso fixed along the way: `ast_scan.py`\n\nand `scan.py`\n\nsilently reported\n\"no findings\" when pointed at a single file instead of a directory\n(`Path(file).rglob(\"*.py\")`\n\nreturns an empty iterator, not an error) — a\nfalse \"all clear\" is the exact failure mode this whole project exists to\nprevent, so worth fixing the moment building/testing `autofix.py`\n\non a\nsingle file actually hit it.\n\n`autofix-weekly.yml`\n\nused to check out this tool's *whole repo* into a\nsubfolder next to the consumer project, just to reach one file\n(`claude-api-guard-tool/autofix.py`\n\n) — noted at the time as a known gap.\nClosed now: `pyproject.toml`\n\npackages `rules.py`\n\n, `ast_scan.py`\n\n, and\n`autofix.py`\n\nas an installable `claude-api-guard`\n\npackage with two\nconsole-script entry points, `claude-api-guard-scan`\n\nand\n`claude-api-guard-autofix`\n\n. `autofix-weekly.yml`\n\nnow does `pip install \"git+https://x-access-token:${TOKEN}@github.com/MarkMoneyMan/Claude-api-goat.git@master\"`\n\nand runs `claude-api-guard-autofix repo --write`\n\n— one step instead of\ntwo, and no more reaching into a sibling checkout's file path by hand.\n\n**One deliberate tradeoff, stated plainly rather than hidden:** the\npackage is flat top-level modules (`rules`\n\n, `ast_scan`\n\n, `autofix`\n\n), not\na `claude_api_guard/`\n\nnamespace package. That's not an oversight — those\nthree files already import each other with bare names\n(`from rules import RULES`\n\n, `from ast_scan import ...`\n\n), and `action.yml`\n\n`self-check.yml`\n\n+`sync_rules.py`\n\nall already run them as plain top-level scripts by path. Packaging them as-is meant**zero** import changes and zero risk to any of that already-working, already-tested machinery — the actual cost is that \"rules\", \"ast_scan\", and \"autofix\" are generic names that could collide with something else in a shared Python environment. Acceptable here because the only realistic install path is a fresh, ephemeral CI job installing straight from this private repo, not a shared environment — but a real`claude_api_guard/`\n\nlayout (with relative imports, and`action.yml`\n\n/`sync_rules.py`\n\nupdated to match) would be the right fix before this goes anywhere wider than that.\n\n**Validated:** installed into a clean virtualenv from this checkout\n(`pip install -e .`\n\nfirst, then `pip install .`\n\nto mirror what CI\nactually does) and run from a directory with no copy of this repo in it\nat all — both console scripts produced byte-identical results to running\nthe scripts directly (`claude-api-guard-scan`\n\nfound the same 4 known\n`example_project/`\n\nfindings and exited 1; `claude-api-guard-autofix`\n\nproduced the same 7 edits against a copy of `autofix_test.py`\n\n, and the\npatched file still parsed). `self-check.yml`\n\ngained a third job,\n`package-installs-and-runs`\n\n, that runs this exact same check on every\npush — so a future change that breaks the installed package (not just\nthe scripts run directly) fails CI immediately instead of only showing\nup the next time `autofix-weekly.yml`\n\nhappens to fire.\n\n**Update:** ran for real on GitHub Actions — `self-check #9`\n\n(commit\n`d8dbe6e`\n\n) passed all three jobs, confirming `pip install .`\n\n(the build\n\n- entry-point registration this sandbox couldn't test, no network path\nto\n`github.com`\n\nfrom here) works correctly on a real Ubuntu runner, not just in this sandbox's virtualenv. Being precise about what that does and doesn't cover:`self-check.yml`\n\ninstalls from the already-checked- out local directory (`pip install .`\n\n), which proves the package itself is sound. It does**not** exercise`autofix-weekly.yml`\n\n's specific`pip install \"git+https://x-access-token:...@github.com/...\"`\n\nline — that only fires on the Monday schedule or a manual`workflow_dispatch`\n\n, neither of which has happened yet. pip's git-URL install and token-in-URL auth are both extremely well-trodden mechanisms, so this is a small remaining gap, not an unknown one — but per this project's own rule of not calling something proven until it's run for real, it stays open until`autofix-weekly.yml`\n\nactually fires once.\n\n`js_scanner/ast_scan.js`\n\n— same \"walk the real tree, match node-by-node,\nnever the whole file\" idea as `ast_scan.py`\n\n, ported to JS/TS. Built the\ngeneric engine directly from the start this time (no separate hand-coded\nphase first) — there was no reason to relearn the lesson from the Python\nside about testing against real repos before trusting a rule set.\n\n**Rule set is deliberately smaller than Python's.** Went back through the\nsame raw changelog text looking specifically for what's confirmed to touch\nthe TypeScript SDK, rather than assuming every Python-flagged change\napplies by analogy. Included: API/request-level changes that don't care\nwhich language calls them (model deprecations, Opus 4.7 fast-mode removal,\nOpus 5 effort+thinking rejection, assistant-prefill removal, experimental\nendpoint retirement), plus the two changes the changelog explicitly names\n\"Python SDK X, TypeScript SDK Y, ...\": the `beta.files`\n\n/`beta.skills`\n\nshape change and the memory-list header change. Excluded: every rule whose\nown title says \"Python SDK v1.0\" (httpx→httpx2, `compaction_control`\n\n,\nasync `.with_raw_response`\n\n, Bedrock's default region, the Python 3.10\nfloor) — those are Python-package-internal, and there's no changelog\nevidence the TypeScript SDK did the same thing. Left as an open question\nrather than guessed.\n\n**First live run found 4 real bugs, same pattern as every other \"test it\nfor real\" pass in this project:**\n\n- A crash, not just noise:\n`@babel/traverse`\n\n's scope-crawling threw an uncaught error on one real file in`vercel/ai`\n\n(a valid-but-unusual TS type/value naming collision) and killed the*entire*batch scan, losing every finding already collected. Fixed with a per-file try/catch, same principle as`ast.parse`\n\n's`SyntaxError`\n\nbeing caught per-file in Python, just a different failure mode (traverse-time, not parse-time). - The same cross-node duplicate-finding bug described in step 3 above — found in Python first, then confirmed live here too by literally translating the same fix and watching it matter immediately.\n`assistant-prefill-removed`\n\nregex-matched**1,619 times** in`vercel/ai`\n\nalone:`role: \"assistant\"`\n\nnear`content:`\n\nis the shape of*any*code representing an assistant chat message at all (rendering history, type defs, test fixtures), not specifically \"the last message of an outgoing request.\" Fixed by pulling this one rule out of the generic engine entirely and porting the precise version of the check from`ast_scan.py`\n\n's`extract_messages_prefill()`\n\n— only the literal last element of an actual`messages.create()`\n\ncall's`messages`\n\narray counts.- Two variations on \"a candidate node's span can be bigger than it\nlooks\": a JS test-framework call like\n`describe('X', () => { ...whole rest of the file... })`\n\nis itself one`CallExpression`\n\n, so anything anywhere in that block counted as a \"match\" on the outer call; a large`expect(x).toMatchObject({ ...huge mock... })`\n\nhas the same problem without being a callback. Fixed the first with a structural check (skip a call whose argument is a function with a real body — traversal still walks into it, so a real Anthropic call nested inside still gets checked on its own node) and the second with a blunter 2000-character snippet cap, documented as a safety valve rather than a precise fix.\n\n**Net result**, tested against `anthropic-sdk-typescript`\n\n(the SDK's own\nrepo — same self-referential-test-bed caveat as the Python side applies)\nand `vercel/ai`\n\n(a real, large downstream consumer): `vercel/ai`\n\nwent\n1,734 → 67 findings across the 4 fixes above, and the 67 remaining check\nout on inspection (real references to `computer_20251124`\n\n, a real\ndeprecated-model-string literal, etc. — see git history for the exact\nbefore/after JSON if you want to see the noise that got cut). Only tested\nagainst 2 real repos so far, not 6 like the Python side — this is\nexplicitly a first pass, not yet hardened to the same degree.\n\nStarted as \"guards your Claude API calls.\" The business case for going\nfurther is straightforward: almost no real project uses exactly one LLM\nAPI forever, so a tool that only watches Anthropic's SDK is watching a\nfraction of the code that's actually at risk. First step: `rules_openai.py`\n\n— 4 rules, hand-extracted the same way `rules.py`\n\noriginally was, from\nOpenAI's real, live sources (`httpx2.md`\n\n's current migration guide,\n`CHANGELOG.md`\n\n's explicit \"BREAKING CHANGES\" markers, and the 2023 v1.0.0\nmigration guide for the still-real risk of old copy-pasted call styles).\n`ast_scan.py`\n\nmerges both rule sets (`RULES = anthropic rules + openai rules`\n\n); a rule with no `\"provider\"`\n\nkey defaults to `\"anthropic\"`\n\nso none\nof the 18 existing rules needed touching by hand.\n\n**One validating detail before any code was written:** the httpx-to-httpx2\nmigration already tracked for Anthropic (`python-sdk-v1-httpx-to-httpx2`\n\n)\nturns out to be the *same* industry event hitting OpenAI's SDK too — both\nare generated by the same tool (Stainless), and httpx itself going\nunmaintained affects everyone built on it. Real, structural evidence this\nisn't a one-off, not just an assumption that \"multi-provider\" is worth\nbuilding.\n\n**Tested against real repos immediately, not assumed correct — and found\ntwo real bugs, same pattern as every other provider/language added to\nthis project so far:**\n\n`file_references_openai()`\n\n(the same per-file import precondition that already gates the Anthropic httpx rule) was a direct copy of`file_references_anthropic()`\n\n— \"does this file import anything under`openai.*`\n\n?\" Tested against`litellm`\n\n(170 httpx-rule hits on first run). Root cause: litellm reuses`openai.types.*`\n\n— OpenAI's own Pydantic response-schema submodule — as a shared return-type vocabulary across*every*provider it supports, including ones with nothing to do with OpenAI. Its Vertex AI (Google) image-generation handler does`from openai.types.image import Image`\n\npurely to borrow that shape, with zero real OpenAI-client code in the file. Fixed by excluding`openai.types(.*)`\n\nimports from the precondition — a bare`import openai`\n\nor`from openai import OpenAI`\n\nstill counts, but borrowing a type definition doesn't. No equivalent gotcha exists on the Anthropic side (its SDK isn't reused as a cross-provider type vocabulary the same way), which is exactly why this wasn't caught by just copying the Anthropic check — it had to be tested for real.- The first version of\n`openai-v2-tool-call-output-type-widened`\n\n's pattern also matched a generic`.output[0]`\n\nshape, meant to catch code indexing into the field directly without naming the type.`.output`\n\nindexed at`[0]`\n\nturned out to be an extremely common, totally generic shape (any response wrapper, any test fixture) — 17 hits in litellm, 14 of them unrelated to this rule at all. Fixed by narrowing the pattern to the two named types themselves (`ResponseFunctionToolCallOutputItem`\n\n/`ResponseCustomToolCallOutput`\n\n), accepting under-reporting (code that reads`.output`\n\nwithout ever naming these types is missed) over noise — same trade-off this project has made every other time a pattern was too permissive.\n\n**Update: fully triaged, not just spot-checked — every one of the 140\nfindings above was reviewed, not a sample.** That triage found three more\nreal, structural bugs, same \"test for real\" pattern as everything else in\nthis project:\n\n- 63 of the 129\n`openai-httpx-to-httpx2`\n\nhits were a bare`import httpx`\n\nor`from httpx import ...`\n\nline, with no actual`httpx.Client`\n\n/`Timeout`\n\n/`MockTransport`\n\nconstruction anywhere else in that file — 43 files had*only*that.`litellm/exceptions.py`\n\nwas typical: it uses`httpx.Response`\n\n/`httpx.Request`\n\nextensively (types this rule was never about), and the import line was the sole match. A bare import isn't actionable on its own — nothing for a developer to go change at that specific line — so this rule's Anthropic sibling (`python-sdk-v1-httpx-to-httpx2`\n\n) got away with matching bare imports too only because litellm barely references`anthropic`\n\nat all and was never stress-tested there. Fixed by dropping the bare-import alternative from the pattern entirely, keeping only the actual construction/type sites. - One of the 8\n`openai-v1-legacy-module-level-calls-removed`\n\nhits wasn't real code at all: litellm's PromptLayer integration does`litellm.module_level_client.post(..., json={\"function_name\": \"openai.ChatCompletion.create\", ...})`\n\n— a real`Call`\n\nnode whose unparsed text includes a**string literal** that merely names the old call shape as logging metadata sent to PromptLayer's API. Nothing there is actually calling`openai.ChatCompletion.create`\n\n; the pattern matched inside a string value because`generic_scan()`\n\nregexes a node's whole unparsed text, code and any string literals it contains alike — a structural gap in the generic engine itself, not just this rule (any rule's trigger text could coincidentally appear inside some unrelated string; this is the first time it's actually been observed, not something audited across every other rule). Given every real hit for this specific rule is a genuine attribute access that's never inside quotes, fixed narrowly with a quote-adjacency guard on this rule's pattern (`(?<!['\"])...(?!['\"])`\n\n) rather than touching`generic_scan()`\n\nitself — safer, and doesn't risk any already-shipped rule that hasn't shown this problem.\n\nAfter all three fixes: **195 → 59 OpenAI-rule findings in litellm, every\none reviewed and legitimate** — real `httpx.Client`\n\n/`Timeout`\n\n/\n`MockTransport`\n\nconstruction or type-check sites (mostly in litellm's\nactual OpenAI/Azure provider code and its HTTP-mocking test fixtures),\nreal leftover legacy `openai.api_key =`\n\n/`openai.ChatCompletion.create(...)`\n\ncalls (an old cookbook example and a few of litellm's own older test\nsetup lines), and the 3 real references to the renamed tool-call-output\ntypes. Re-confirmed clean afterward: `openai-cookbook`\n\nstill 0 findings,\n`ci_fixtures/known_clean.py`\n\nstill 0, `example_project/`\n\n's own fixtures\nunaffected.\n\nAlso tested against `openai-cookbook`\n\n(OpenAI's own official examples,\n224 real `.py`\n\nfiles — 0 findings throughout, a clean smoke test on\nactively-maintained modern code).\n\n**Update: OpenAI is now wired into rule sync and self-check too, closing\nthe loop the same way it's closed for Anthropic.** `sync_rules.py`\n\nis\nmulti-provider now (`--provider anthropic|openai`\n\n; see \"Rule sync\"\nbelow for exactly how the two providers' changelogs are parsed\ndifferently), `update-rules.yml`\n\nruns it for both every week and opens\none combined PR, and `self-check.yml`\n\nhas a dedicated job that greps for\n`openai-httpx-to-httpx2`\n\nand `openai-v1-legacy-module-level-calls-removed`\n\nby name (not just \"the severity gate failed\") so a silent regression in\none specific OpenAI rule can't hide behind some other rule still firing.\n`pipeline_runs/last_synced.json`\n\nis now `{\"anthropic\": {...}, \"openai\": {...}}`\n\n(migrated automatically from the old flat one-provider shape,\ntested against a simulated old file, not just assumed).\n\n**What's explicitly not done yet, stated plainly:** the string-literal\nfalse-positive class found in bug #4 is a real gap in `generic_scan()`\n\nitself, not just this one rule — it hasn't been audited across the other\n21 rules to see whether any of them are exposed to it too (none have\nshown it in the repos tested so far, but \"not yet observed\" isn't the\nsame as \"doesn't happen\"). JS/TS support for OpenAI is still untested —\n`js_scanner/`\n\nonly knows the Anthropic rule set right now — and the\nOpenAI side of rule sync hasn't been proven against a real *new*\nbreaking change yet (unlike Anthropic's, which was — see \"Rule sync\"\nbelow): it's only been run in dry-run mode against real history, since\nthere's no small, cheap way to roll OpenAI's `last_synced_date`\n\nback\nwithout re-processing content already reviewed by hand. It'll get its\nreal end-to-end test whenever openai-python next ships a version with an\nactual `⚠ BREAKING CHANGES`\n\nsection and the Monday schedule (or a manual\nrun) picks it up — same \"this part waits for something real to happen\"\nhonesty already applied to Anthropic's own first automated run.\n\n`sync_rules.py`\n\nis what actually makes this project \"self-maintaining\"\ninstead of \"a scanner someone has to remember to update by hand.\" It:\n\n- fetches\n`https://platform.claude.com/docs/en/release-notes/overview.md`\n\n— appending`.md`\n\nto a`platform.claude.com/docs/...`\n\nURL returns raw markdown instead of the rendered page, found by trying it, not documented anywhere, and much easier to parse reliably than scraping HTML; - splits it into dated sections and keeps only the ones newer than\n`pipeline_runs/last_synced.json`\n\n's stored date, so a weekly run doesn't re-fetch and re-pay for the same 2+ years of history every time; - hands just the new text to\n`extract_rules.py`\n\n's`extract()`\n\n— the same extraction used for the one-off manual run that seeded`rules.py`\n\n; - drops any extracted rule whose\n`id`\n\nalready exists in`rules.py`\n\n(defense against the same change getting described slightly differently on a re-run); - appends whatever's left as a new dated block (\n`RULES_AUTO_<date> = [...]`\n\n`RULES = RULES + RULES_AUTO_<date>`\n\n), and advances the synced-through date regardless of whether anything new was found, so a week with only additive (non-breaking) changes doesn't get re-processed forever.\n\n**Multi-provider since the OpenAI work above** — this whole pipeline runs\nonce per provider (`python3 sync_rules.py --provider anthropic|openai`\n\n),\neach with its own entry in a `PROVIDERS`\n\ndict: its own changelog URL, its\nown rules file (`rules.py`\n\n/ `rules_openai.py`\n\n), and — this is the part\nthat couldn't be shared code — its own section parser. Anthropic's\nrelease notes and openai-python's `CHANGELOG.md`\n\naren't just different\nURLs, they're structurally different documents: Anthropic's is\nunstructured prose with no reliable breaking/non-breaking signal beyond\nwhat the model infers, so every new dated section has to go to it.\nopenai-python's `CHANGELOG.md`\n\nexplicitly marks breaking versions with a\n\"`### ⚠ BREAKING CHANGES`\n\n\" heading (confirmed against the real file: 344\nversion headers total, ever, only 2 ever marked breaking) — so its parser\nfilters to *only* those sections before anything reaches the model,\nrather than spending tokens sending it 342 irrelevant Features/Bug\nFixes/Chores sections to correctly say \"nothing breaking here\" over and\nover. `pipeline_runs/last_synced.json`\n\nis one file holding one entry per\nprovider now instead of a single flat date; an old flat-shaped file (from\nbefore a second provider existed) is migrated to the new shape\nautomatically the first time it's read.\n\n`.github/workflows/update-rules.yml`\n\nruns both providers weekly (Mondays)\nand on `workflow_dispatch`\n\n, then hands off once to\n`peter-evans/create-pull-request`\n\nfor whatever changed across either —\nsame no-commit-if-nothing-changed pattern as `autofix-weekly.yml`\n\n, on\na fixed branch name so a run before last week's PR merges updates that\nPR instead of opening a duplicate. Needs a repo secret,\n`ANTHROPIC_API_KEY`\n\n— the workflow fails loudly rather than silently\nskipping if it's missing (no OpenAI API key is needed anywhere in this:\nthe OpenAI side only ever reads OpenAI's public changelog page, it never\ncalls OpenAI's own API). Also needs the repo's \"Allow GitHub Actions to\ncreate and approve pull requests\" setting enabled (Settings → Actions →\nGeneral → Workflow permissions) — without it, `create-pull-request`\n\nfails even with `pull-requests: write`\n\ndeclared in the workflow itself\n(found the hard way; see below).\n\n**What's tested and how, stated plainly:** this cloud environment's own\nnetwork egress blocks `platform.claude.com`\n\ndirectly (confirmed — a plain\n`curl`\n\nand `urllib.request`\n\nboth get rejected by the sandbox's proxy, an\nenvironment restriction, not a bug in the fetch code), so the actual\n`fetch_changelog_markdown()`\n\nHTTP call hasn't run inside this box. It has\nbeen tested with the *real* page content, though: `WebFetch`\n\n(which goes\nthrough a different path) pulled the live `.md`\n\npage directly, and that\nreal output — all 135 dated sections back to May 2024 — was fed through\nthe parser and dedupe/merge logic directly. That's how a real bug got\ncaught before this ever ran unattended: older entries use ordinal day\nsuffixes (\"`April 9th, 2025`\n\n\", \"`March 31st, 2025`\n\n\") that `strptime`\n\ncan't parse, while recent ones don't (\"`August 27, 2026`\n\n\") — the first\nversion silently dropped every suffixed section instead of erroring,\nwhich would have been a **quiet under-processing bug**, not a crash (the\nexact failure shape this whole project tries to catch in *other* code).\nFixed by stripping the suffix before parsing; re-tested against the same\n135 sections, all parse correctly now. Separately verified end-to-end\nwith synthetic candidate rules (bypassing the real API call): dedup\ncorrectly skips a rule whose id already exists, keeps a genuinely new\none, appends a block that keeps `rules.py`\n\nparsing as valid Python, and\nthe newly appended rule is immediately usable by `ast_scan.py`\n\n— it\nfound the synthetic rule's trigger pattern in a test fixture, same as any\nhand-written rule would. **Update:** ran for real on GitHub Actions\n(`update-rules.yml`\n\nrun #1, `workflow_dispatch`\n\n, after the\n`ANTHROPIC_API_KEY`\n\nsecret was added) — succeeded in 14s. That confirms\nthe actual `fetch_changelog_markdown()`\n\nHTTP call works from a real\nrunner (this sandbox's own egress blocks it, so it had only ever been\nexercised with a pre-fetched copy of the page before this), and that the\nsecret is read correctly. The 14s runtime is itself informative: too\nfast to have called the model, consistent with hitting the \"nothing new\nsince 2026-08-27\" fast path and exiting before ever importing\n`extract_rules`\n\n. Confirmed on GitHub afterward: no PR was opened — the\n\"nothing changed, don't bother `create-pull-request`\n\n\" path behaves\ncorrectly for real, not just in the code reading right.\n\n**Update: the extraction call itself has now been tested for real, too**\n— deliberately, not by waiting for Anthropic to publish something new.\n`pipeline_runs/last_synced.json`\n\nwas rolled back to an earlier date on\npurpose (a small, disclosed, real API cost) so the next run would treat\nalready-public content as \"new\" and actually exercise the model call and\neverything downstream of it. First attempt (`update-rules.yml`\n\nrun #2)\ncrashed: `json.decoder.JSONDecodeError: Invalid \\escape`\n\n. Root cause: the\nextraction prompt asks the model for a `\"pattern\"`\n\nfield containing a raw\nregex, and the model wrote single backslashes (e.g. the literal text\n`\\.`\n\n) instead of the two backslashes valid JSON requires to represent one\nbackslash character. `extract_rules.py`\n\nnow (a) tells the model\nexplicitly, with worked examples, that every backslash in that field must\nbe doubled, and (b) repairs any stray single backslash before the first\nparse attempt regardless of whether parsing would otherwise succeed —\nbecause `\\b`\n\nspecifically is *valid* JSON (it decodes to a backspace\ncontrol character) while meaning something unrelated in regex (word\nboundary), so a repair-only-on-crash design would let that one through\nsilently: a rule that looks fine, ships fine, and then just never matches\nanything. Caught a bug in that repair itself during local testing, before\nit ever reached CI — the first version could corrupt an\nalready-correctly-escaped `\\\\b`\n\ninto `\\\\\\b`\n\n— fixed and re-verified\nagainst all three cases (the crash pattern, the silent-corruption\npattern, and the already-correct pattern) before redeploying. Second\nattempt (run #3) got past extraction cleanly but failed at a different,\nunrelated step: `peter-evans/create-pull-request`\n\ncouldn't open a PR —\n\"GitHub Actions is not permitted to create or approve pull requests,\"\na repo-level setting (Settings → Actions → General → Workflow\npermissions), not a code bug, even though the workflow already declared\n`pull-requests: write`\n\n. Fixed by enabling \"Allow GitHub Actions to create\nand approve pull requests\" on the repo. Third attempt (run #4) succeeded\nend-to-end in 59s and opened a real PR (`#1`\n\n,\n\"claude-api-guard: new rules from Anthropic's release notes\"), confirmed\non GitHub. That's the full loop validated for real: fetch → parse →\nextract via the model → dedupe → append → PR, with two real bugs found\nand fixed along the way instead of assumed away.\n\n`action.yml`\n\npackages the Python (and optionally JS/TS) scanner as a\ncomposite GitHub Action, so a project can get checked on every PR instead\nof someone running `ast_scan.py`\n\nby hand and remembering to. Two pieces:\n\n-\n— the action itself. Runs`action.yml`\n\n+`action_combine.py`\n\n`ast_scan.py`\n\n(and`js_scanner/ast_scan.js`\n\nif`scan-js: true`\n\n), merges whatever findings files actually exist, and fails the job only at or above a configurable`fail-on`\n\nseverity (default`HIGH`\n\n) — a MEDIUM/LOW heads-up shouldn't block a merge the way a HIGH one should. -\n— dogfoods the action against`.github/workflows/self-check.yml`\n\n*this*repo on every push: one job asserts the severity gate correctly**fails** against`example_project/`\n\n(which has known HIGH findings by design), the other asserts it correctly**passes** against a dedicated known-clean fixture,`ci_fixtures/known_clean.py`\n\n. Both are assertions about the action's own correctness, not about this repo's code health.That second job originally pointed at\n\n`rules.py`\n\nitself, on the reasoning \"it doesn't call the Anthropic API, so it should be clean.\" The first real run on GitHub Actions (run #1, commit`fedb0e7`\n\n) came back red. Reproduced locally with`python3 ast_scan.py rules.py`\n\n: 7 findings, several HIGH. The reasoning was wrong — \"doesn't call the API\" and \"contains no matching text\" aren't the same property, and`rules.py`\n\n's entire job is to store the literal trigger strings (like`client.beta.files`\n\n,`managed-agents-2026-04-01`\n\n) as rule data, so the generic engine's`ast.Assign`\n\nmatching legitimately finds them there. Fixed by pointing the job at a small, deliberately unrelated fixture file instead of reusing a file whose actual purpose guarantees it can never be \"clean.\" Caught by getting a real Actions run — this is exactly the class of bug local YAML validation and the unit-tested Python logic couldn't have found (see below). -\n— two templates (`examples/consumer-workflows/`\n\n`check-on-pr.yml`\n\n, a weekly`autofix-weekly.yml`\n\nthat opens a PR via the well-established`peter-evans/create-pull-request`\n\naction when`autofix.py`\n\nfinds something to fix) showing how a*downstream*project would wire this in. Now point at the real`MarkMoneyMan/Claude-api-goat@master`\n\ninstead of a placeholder — see \"Publishing\" below for the access caveats that come with that repo being private.\n\n**What's validated and what isn't, stated plainly:** all 4 YAML files\nparse as valid YAML, and the Python logic each step actually calls\n(`ast_scan.py`\n\n's exit code, `action_combine.py`\n\n's severity gate and\n`$GITHUB_OUTPUT`\n\nwriting) was tested directly and behaves correctly across\nall 3 cases that matter — findings at/above threshold, findings below\n`fail-on`\n\n, and no findings. Local testing stopped there: `nektos/act`\n\n(a\nlocal Actions runner) installed fine but needs a Docker daemon to spin up\nrunner containers, and this environment doesn't have one running\n(`docker info`\n\nconfirms no daemon, not just a missing CLI).\n\nThat gap got closed for real once the repo was published (see\n\"Publishing\" below): `self-check.yml`\n\nran on actual GitHub Actions and\nimmediately found a real bug — the `rules.py`\n\n-as-known-clean-fixture\nmistake described above — that no amount of local YAML validation or\nunit-tested Python logic could have surfaced, because the bug wasn't in\nthe YAML wiring or the scanner logic, it was in a *test's assumption*\nabout its own fixture. After swapping in `ci_fixtures/known_clean.py`\n\nand re-pushing, run #2 (commit `7a97f7f`\n\n) went green on both jobs —\nconfirmed end-to-end on real GitHub Actions, not just locally. That's\nthe whole point of dogfooding this against a real remote instead of\nstopping at \"the YAML looks right\": the bug this section describes only\nexisted to find because a real run happened.\n\nPublished to a real (private) GitHub repository:\n`github.com/MarkMoneyMan/Claude-api-goat`\n\n. Getting there needed two\nrounds of Personal Access Token permission fixes — GitHub refuses to let\na token without \"Workflows\" scope push changes to `.github/workflows/*`\n\n,\neven if it already has \"Contents: Read and write\" — which isn't obvious\nuntil the push is rejected with that exact error.\n\nBoth consumer-workflow templates now point at the real\n`MarkMoneyMan/Claude-api-goat@master`\n\ninstead of the old\n`YOUR-GITHUB-USERNAME`\n\nplaceholder, but \"private\" isn't free to work\naround — two different mechanisms are involved, and they were kept\nseparate deliberately rather than papered over:\n\n`check-on-pr.yml`\n\n's`uses: MarkMoneyMan/Claude-api-goat@master`\n\n(an*action reference*) works for a same-account repo like OddsScanner with no extra setup — GitHub's repo Settings → Actions → General → \"Access\" on Claude-api-goat covers this case, and same-account repos get it by default.`autofix-weekly.yml`\n\n's`actions/checkout`\n\nstep with`repository: MarkMoneyMan/Claude-api-goat`\n\n(*cloning a second repo's contents*, to get`autofix.py`\n\nitself) is a different mechanism — the default`GITHUB_TOKEN`\n\na workflow run gets is scoped only to the repo it's running in, same-account or not. That step needs a`token:`\n\ninput pointing at a PAT (read-only \"Contents\" scope on Claude-api-goat is enough) stored as a secret in the*downstream*repo. Not yet set up in OddsScanner — that's the actual remaining step now, not the placeholder swap.", "url": "https://wpnews.pro/news/claude-api-guard-a-ci-check-that-catches-claude-openai-sdk-breaking-changes", "canonical_source": "https://github.com/MarkMoneyMan/Claude-api-goat", "published_at": "2026-09-03 15:38:30+00:00", "updated_at": "2026-09-03 15:53:25.525762+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "artificial-intelligence"], "entities": ["MarkMoneyMan", "Claude-API-guard", "Anthropic", "OpenAI", "GitHub Actions", "litellm"], "alternates": {"html": "https://wpnews.pro/news/claude-api-guard-a-ci-check-that-catches-claude-openai-sdk-breaking-changes", "markdown": "https://wpnews.pro/news/claude-api-guard-a-ci-check-that-catches-claude-openai-sdk-breaking-changes.md", "text": "https://wpnews.pro/news/claude-api-guard-a-ci-check-that-catches-claude-openai-sdk-breaking-changes.txt", "jsonld": "https://wpnews.pro/news/claude-api-guard-a-ci-check-that-catches-claude-openai-sdk-breaking-changes.jsonld"}}