Claude CLI vs API for Code Review: Same Model, Wildly Different Results

Results screenshot

I stopped writing code by hand a while ago. Claude writes it, I review it, it ships. It works, so why should I?

But here’s the thing — if AI writes all the code, who reviews it? Another AI, obviously. So I built brunt, an adversarial code review tool that throws LLMs at your diffs to find bugs and security issues.

The problem is: which AI do you point it at? I have a Claude subscription (CLI access), and I have an API key. Same company, same models. Should give the same results, right? I also gave Ollama a try, didn’t make the cut.

I tested this against a real refactor on my Rust/Axum backend — replacing four old subsystems with a new AI scenarios feature. 20 commits, 77 files, +1,566 / -5,900 lines. I ran brunt three ways:

  1. Claude CLI — uses your Claude subscription via claude -p
  2. Anthropic API (Sonnet) — claude-sonnet-4-6 via HTTP
  3. Anthropic API (Opus) — claude-opus-4-6 via HTTP

Same diff. Same tool. Same prompts. Wildly different results.

The results

Results screenshot

Seven findings vs eighty-four findings. Same model family, same prompts. What happened?

More findings is not better

The CLI run found 7 issues. Every single one was a real, actionable bug. The best catch: a missing .await on an async function call that silently dropped a Future — the scenario trigger would never fire.

// Bug: this creates a Future but never polls it
state.scenario_trigger.on_activity_created(
    user.tenant_id, &activity, &state
);
// Should be:
state.scenario_trigger.on_activity_created(
    user.tenant_id, &activity, &state
).await;

Rust compiles this without error. It just silently does nothing. That is exactly the kind of bug you want an AI reviewer to catch.

Sonnet’s 84 findings included a lot of noise. It flagged bugs in deleted code — code that no longer exists in the codebase. It reported concerns about parameter binding in functions that were entirely removed in the same PR. Technically correct observations about the diff in isolation, but not real bugs.

Opus found 44 issues. Eight were marked critical — but they were all “removed module declaration breaks dependents.” True if you only see one file, false when you realize the dependents were also removed in the same PR. The model couldn’t see across files.

The takeaway: A noisy reviewer that cries wolf on 84 issues trains you to ignore findings. A precise reviewer that surfaces 7 real concerns gets your attention.

The debugging adventure

Before I got these results, I spent an hour (really, an hour?) debugging why the API runs returned zero findings.

The first API run completed in 3 seconds for 75 files and found nothing. That’s suspicious — 75 LLM calls in 3 seconds is physically impossible. Something was silently failing.

Bug 1: Wrong model ID

The config had claude-sonnet-4-6-20250514 as the model. The Claude CLI resolves this alias fine. The Anthropic API does not — it returns 404. Every single API call was failing.

But brunt uses Promise.allSettled to collect per-file results:

const perFileResults = await Promise.allSettled(
  files.map((file) => vector.analyze([file], context, provider))
);
const findings = perFileResults.flatMap((r) =>
  r.status === "fulfilled" ? r.value : []
);

Rejected promises get silently mapped to empty arrays. Zero findings, zero errors shown to the user. The output? “No issues found.” Completely misleading.

Bug 2: No concurrency limiting

The engine fires all 75 files simultaneously as parallel API calls. Per vector, that is 75 concurrent requests. Two vectors = 150 concurrent HTTP requests hitting Anthropic at once.

Result: mass rate limiting (HTTP 429). Same silent failure — all rejected promises dropped.

Bug 3: Output token limit too low

The default max_tokens was 4096. For a file that produces many findings, the response gets truncated mid-JSON. The parser fails. Zero findings.

Bug 4: No retry on rate limiting

The provider threw immediately on any non-200 response. No backoff, no retry. One 429 and the analysis for that file is gone.

All four bugs shared one pattern: the tool silently produced empty results instead of erroring. “No issues found” when the analysis didn’t actually run. This is worse than a crash — it builds false confidence.

The fixes

Concurrency limiting — added a worker pool that limits API calls to 5 at a time:

async function runWithConcurrency<T>(
  tasks: (() => Promise<T>)[],
  concurrency: number
): Promise<PromiseSettledResult<T>[]> {
  const results: PromiseSettledResult<T>[] = new Array(tasks.length);
  let nextIndex = 0;

  async function worker() {
    while (nextIndex < tasks.length) {
      const idx = nextIndex++;
      try {
        results[idx] = { status: "fulfilled", value: await tasks[idx]!() };
      } catch (reason) {
        results[idx] = { status: "rejected", reason };
      }
    }
  }

  const workers = Array.from(
    { length: Math.min(concurrency, tasks.length) },
    () => worker()
  );
  await Promise.all(workers);
  return results;
}

Retry with backoff — exponential backoff on 429/529, respecting retry-after headers:

if (response.status === 429 || response.status === 529) {
  if (attempt === MAX_RETRIES) {
    throw new Error(`API error (${response.status}) after ${MAX_RETRIES} retries`);
  }
  const retryAfter = response.headers.get("retry-after");
  const backoff = retryAfter
    ? parseInt(retryAfter, 10) * 1000
    : INITIAL_BACKOFF_MS * Math.pow(2, attempt);
  await sleep(backoff);
  continue;
}

Bumped max_tokens to 16384 and used correct model IDs (claude-sonnet-4-6, not claude-sonnet-4-6-20250514).

After these fixes, the API runs actually worked — and produced real results.

Non-determinism is real

I ran Claude CLI twice on the same diff. First run: 10 findings, canary detected. Second run: 7 findings, canary missed. That is a 30% variance between identical runs.

Brunt plants a synthetic bug (a “canary”) in the diff and checks if the model catches it. It is a clever reliability signal — if the model misses the canary, results are flagged as potentially unreliable. But the canary itself is non-deterministic. Claude caught it once, missed it the next time.

This means you cannot use a single AI review run as a pass/fail gate. The same code will get different reviews depending on when you run it. If you are building CI pipelines around AI review tools, you need to account for this — run multiple times, take the union of findings, or use a consensus mechanism.

CLI vs API: the real differences

Claude CLI and the Anthropic API can use the same underlying model. But for tool builders, the experience is very different:

Claude CLI Anthropic API
Rate limiting Handled for you Build it yourself
Retries Built in Build it yourself
Model aliases Work seamlessly Must use exact model ID
Default model Your subscription tier Must specify explicitly
Cost Included in subscription Pay per token (~$1/run)
Speed Slower (subprocess per call) Faster once infrastructure is right
Concurrency Managed internally You control it

The CLI is the easier path. You get rate limiting, retries, model resolution, and a generous context window for free. The API gives you more control but you are responsible for everything — and as I learned, getting “everything” right is harder than it looks.

What the models actually found

Across all runs, here are the real issues that held up to manual review:

  • Missing .await on async call — scenario trigger future silently dropped
  • CSV formula injection in data export — unsanitized strings like =HYPERLINK("http://attacker.com") execute in Excel
  • TOCTOU race condition — duplicate scenario executions between has_pending check and create
  • Negative LIMIT in SQL — ListExecutionsQuery.limit accepts negative values, PostgreSQL treats negative LIMIT as unlimited
  • Cursor pagination bug — paginating on non-unique created_at silently skips records with identical timestamps
  • Shutdown signal ignored — 180-second initial sleep blocks graceful shutdown
  • No schema validation on UpdateAiConfig — accepts arbitrary JSON, downstream code indexes into it blindly

Every one of these is a real bug that a human reviewer might miss. The missing .await is particularly nasty — it compiles, it runs, it just silently does nothing.

Lessons for AI tool builders

1. Fail loudly. “I couldn’t analyze this file” is infinitely better than “I found nothing” when the analysis didn’t run. Silent failures in AI tooling are worse than crashes because they build false confidence.

2. More findings is not better. Optimize for signal-to-noise ratio, not raw count. 7 actionable findings beat 84 noisy ones.

3. Account for non-determinism. LLM outputs vary between runs. If your tool is a CI gate, run it multiple times or implement consensus logic.

4. Per-file analysis has blind spots. Analyzing files independently misses cross-file issues. Consider a hybrid: per-file scan for depth, plus one cross-file summary pass for systemic issues.

5. Test your tool with the actual provider you ship. I had four bugs that only manifested with the API provider because development and testing happened with the CLI. If you support multiple backends, test all of them.

The numbers, one more time

Claude CLI API Sonnet API Opus
Findings 7 84 44
Real bugs 7 ~15 ~12
False positives 0 ~69 ~32
Signal ratio 100% ~18% ~27%
Cost Subscription $1.11 $1.67
Time 1m 47s 7m 20s 8m

The CLI run was the most useful: fastest, cheapest, highest signal. But it analyzed fewer files due to subprocess limits. The API runs were thorough but noisy — they need better filtering to be practical.

The ideal setup might be: CLI for fast feedback in development, API with deduplication and filtering for thorough CI reviews.

This is just a demo and should not be used in production systems, personally I have something similar to test my code but with multiple features whereas this tool is more like a general tool to test the model benchmarks, maybe someone gets an idea or inspiration to try something else in their stack. <— This section was actually written by me.

No models were harmed during the test.

P.S. brunt can also generate failing tests and fixes for the issues that were found, but thats a story for another time, thanks for reading!

The codebase reviewed is a real Rust/Axum backend with multi-tenant isolation, async patterns, and PostgreSQL. All findings shown are from actual brunt runs, not curated or cherry-picked.

How to Break Your PostgreSQL IIoT Database and Learn Something in the Process

As engineers, we’re taught to design for reliability. We do design calculations, run simulations, build and test prototypes, and even then we recognize that these are imperfect, so we include safety factors. When it comes to the Industrial Internet of Things (IIoT) though, we rarely give the same level of scrutiny to the components that we rely on.

What if we treated our IIoT database the same way we treated the physical things we produce? We build and design a prototype database, and then put it through some serious testing, even to failure.

The Value (and Perils) of Stress Testing

Think of database stress testing as a destructive materials test for your data storage. You wouldn’t trust a bridge made of untested steel, so don’t trust your database until you know its limits.

The Value:

  • Identify Bottlenecks: Stress testing reveals the weak links—what is likely to fail first? Will you run out of storage? Will your queries get bogged down? Or will you hit the dreaded ingest wall (when data comes in faster than it can be stored)?
  • Determine Real-World Behaviour: You’ll find out exactly how your database performance changes as the amount of data increases. What issues are future-you going to struggle with?
  • Optimize Configuration: Just like you might build a few different prototypes and see how it affects failure modes, changing your database configuration, especially when it comes to indices, can dramatically affect how it behaves. Building a rigorous stress testing framework provides a safe way to optimize your design.

I hope it goes without saying, but please, please don’t run this on your production environment. Even if it’s technically a different database but the same hardware, this test can wreak havoc on your resources and crash your system. You’ve been warned.

What to Measure?

There’s no point going through all the effort to break your system if you don’t learn anything. Assuming you’re using a PostgreSQL database (It’s 2026, Just Use PostgreSQL), here is a decent set of metrics to keep track of while you’re putting your database through its paces.

Table Size

The size of a Postgresql table is generally measured by number of rows, but the actual space on disk that it occupies is a sum of the heap (the main relational table), the indices, and the TOAST (storage for large objects).

The following query will give the number or rows as well as the size of each component of the table in bytes.

SELECT
      reltuples::bigint AS row_count,
      pg_relation_size('iiot_history') AS heap_size,
      pg_indexes_size('iiot_history') AS indices_size,
      pg_table_size('iiot_history') -
            pg_relation_size('iiot_history') AS toast_size
FROM pg_class WHERE relname = 'iiot_history';

The reason for the odd row_count is that counting rows the standard way, with COUNT(*), requires scanning the whole table, which is going to be painfully slow when we’re building a table big enough to break things.

Table Performance

The best way to measure table performance is to use the actual queries that your production system will use. At a minimum, this should include your batched INSERT (you always batch, right?) and at least one common SELECT. Keep in mind that for a table with N rows, the timing for queries tend to be either constant, log(N), N or worse depending on how the indices are structured.

You can get very accurate timing info from running your queries with the prefix EXPLAIN ANALYZE, and it’s worth doing this at least once to see what the database is doing under the hood. However, I recommend running the whole test with a scripting language and then just timing the execution of that particular step.

Server Performance

Don’t forget the engine that’s driving all this machinery. You’ll need to watch the CPU, Memory, Storage, and Network Bandwidth. People in the IT world tend to talk about headroom for a server, and that’s what you’re really looking at: how much spare capacity do you have? Your CPU and Memory usage might spike at times, but the important thing is that it’s not always running at max capacity.

There are a lot of free and paid tools to monitor these variables. I almost always do this type of test in a VM (easier to clean up the mess when it all breaks) and I like to use Prometheus but honestly Perfmon in Windows or Top in Linux gives you all you really need.

Setting Limits

It’s helpful to set some limits on these parameters so you know when to stop the test. For database size, it might be some measurement like a year’s worth of data, or when the drive is 80% full. For ingest timing, I suggest stopping when inserting takes longer than the desired ingest frequency—this is the ingest bottleneck and something you really want to avoid in production. Scan times can be limited by the time it takes for a specific query. Maybe calculating the average value from one tag over the past hour must be less than 10s.

How to Simulate Data?

There are lots of ways to insert data, but it’s usually a tradeoff between how well the data represents real scenarios and how long it takes to run the test.

The following is one of my favourite methods for injecting large amounts of data into an IIoT database:

Say you have a classic IIoT history table like the following:

CREATE TABLE iiot_history(
    time TIMESTAMPZ NOT NULL,
    tag_id INT NOT NULL,
    value DOUBLE PRECISION,
    PRIMARY KEY (tag_id, time)
);

If you expect to ingest 10,000 tags at 1s intervals, you can use the following INSERT query to add a day’s worth of history to the back end of your table.

INSERT INTO iiot_history(time, tag_id, value)
    SELECT *, random() as value 
FROM(
        SELECT generate_series(
            min_date-INTERVAL '1day',
            min_date-INTERVAL '1s',
            INTERVAL '1s') as time
        FROM (SELECT LEAST(NOW(),MIN(time)) AS min_date 
FROM iiot_history)
),
        generate_series(1,10000) as tag_id;

This will generate random data values for every second during a day and for every tag_id from 1 to 10,000. Not exactly as interesting as real data, but enough to fill up your table.

The nice thing about this query is that you should be able to run it in parallel to your real-time data pipeline and it won’t mess with your data (aside from potentially locking your table while it runs). It’s also easy to modify this query to inject more or less tags as well as change the time interval if you’re playing around with different configurations.

If you use this query, or whichever one you prefer, in a script (I usually use Python), then you can automate the whole test. Something along the lines of:

  1. Get database size
  2. Run select queries, measure execution time
  3. Run insert queries several times, measure and average execution time
  4. Artificially grow database size
  5. Repeat 1-3 until one of the failure conditions is reached.

How to Interpret Results and What to Expect in the Real World?

Your test results will give you some clear data points, but you still need to do some interpreting.

  • Identify the Limiting Component: Where did the database fail? If it’s a query that took too long, you might be able to speed things up with a clever index. If it’s an insert that took too long, you might be able to speed things up by removing that clever index you added earlier.
  • Optimize: There’s a lot you can do to improve table performance before throwing the whole thing out in frustration:
    1. Proper Indexing: Choosing an index is almost always a tradeoff, for example: Indexing the tag_id column before the time column will speed up most queries, at the cost of slower inserts as the table grows. Indexing the time column first will avoid the ‘ingest wall’ at the cost of slower queries. Figure out which solution is best.
    2. Plan for the future: Will you need more hardware in a few months or a few years? Being able to estimate the life of your existing architecture means you won’t be caught unawares when it no longer suffices.
    3. Partitioning/Chunking: For very large tables, you may need to partition appropriately (see PostgreSQL extensions like TimescaleDB). How great would it be to learn you’ll need this before you actually need this.
  • Add a Safety Factor: If your test showed a maximum reliable throughput of 15,000 rows/sec, set your operational limit to 10,000 rows/sec. The real world has peaks, unexpected queries, and background maintenance tasks that will steal resources. Like we do with all engineering products, design with margin.

If you treat your database like a prototype and really put it through its paces, you’ll get a preview of how it’ll behave in the future and make good, proactive design decisions instead of struggling in the future. Now, go break something (and learn).

ABD'deki okullarda alarm: Epstein'in oyununu yaptılar

ABD’de öğrenciler arasında hızla yayılan “Five Nights at Epstein’s” adlı tartışmalı oyun, eğitim kurumlarını harekete geçirdi. Jeffrey Epstein ismiyle ilişkilendirilen ve hassas temalar içeren oyunun okul bilgisayarlarında oynanması, hem velilerde hem de yetkililerde endişe yaratırken, okullar erişimi engellemek için acil önlemler almaya başladı. Oyunda, kullanıcılar Jeffrey Epstein’ın adının geçtiği adada “hayatta kalmaya çalışan bir karakteri” canlandırıyor.

OMÜ'de “Çay Saatinde Buluşma” etkinliği

Ondokuz Mayıs Üniversitesi Teknoloji Transfer Ofisi ile Samsun Teknopark iş birliğinde düzenlenen ‘Çay Saatinde Buluşma’ etkinliği, akademi ve sanayi dünyası temsilcilerini bir araya getirerek iş birliği fırsatlarını değerlendirildi.

ASELSAN'ın LUNA-2 uydusu başarıyla fırlatıldı

ASELSAN, uzay tabanlı IoT çözümü için geliştirdiği LUNA-2 uydusunu SpaceX aracılığıyla Kaliforniya’dan başarıyla fırlattı. Bu gelişme, Türkiye’nin uzay teknolojileri alanındaki rekabet gücünü artırıyor.