Pular para o conteúdo
← Voltar para projetos

PHP Sdk

The official PHP client for the ToxicFilter content moderation API.

ToxicFilter PHP SDK

#ToxicFilter PHP SDK

The official PHP client for ToxicFilter.

composer require edulazaro/toxicfilter-sdk
use ToxicFilter\Client;

$tf = new Client(getenv('TOXICFILTER_KEY'));

$verdict = $tf->text('Check this message', [
    'locales' => ['en'],
    'surface' => 'comment',
    'reference' => 'comment_9931',
]);

if ($verdict->blocked()) {
    return refuse();
}

if ($verdict->needsReview()) {
    return hold($verdict->id(), $verdict->reasons());
}

publish();

Three decisions, not two. review is where the uncertainty is allowed to live: forced to choose between publishing and deleting, a threshold set safely deletes real posts and one set kindly publishes the abuse. There is no isToxic() here for the same reason: fifteen categories collapsed into one boolean is somebody else's policy in your code.

#What it does for you

Retries the right failures and never the wrong one. A 429 or a 5xx is asked again with a growing wait; a QuotaExhausted is not, ever. Those two are constantly confused and the correct behaviour is opposite: retry a rate limit, stop dead on a quota.

Makes those retries safe. Every call carries an Idempotency-Key, generated per call, so a request that timed out and is asked again is judged once and billed once.

Waits as long as the service asked, and no longer. A 429 carries retry_after, and honouring it beats guessing: the service knows when its own window turns over. It is bounded by maxWait (30 seconds by default) all the same, because a number on the wire should not decide how long your own request hangs.

use ToxicFilter\Exception\QuotaExhausted;
use ToxicFilter\Exception\RateLimited;

try {
    $verdict = $tf->text($comment);
} catch (QuotaExhausted $e) {
    // Stop calling. No amount of retrying produces credits.
    alert("out of credits, {$e->remaining()} left, renews {$e->renewsAt()}");
} catch (RateLimited $e) {
    // Already retried, and still too many. Back off properly.
}

#When the model is down

A verdict reached without the model because the provider was failing comes back with degraded set, and is billed as the cheap call. It is a separate field from used_ai on purpose: one says the cheap detectors were enough, the other says nobody read it, and only the first is reassuring. Hold or queue what matters to you when you see it.

#Rules without a policy

Send the line you care about and nothing else is acted on. No stored policy is looked up, and no default of ours is laid underneath.

$verdict = $tf->text($comment, [
    'rules' => ['thresholds' => ['sexual' => ['block' => 0.7]]],
]);

A category you did not mention still scores and still appears in signals; it just does not decide anything. policy and rules in the same call is a 422, and so is a name that is not a real category, subject or lead type: a line that acts on nothing looks exactly like a line that works.

#The rest of the answer

$verdict->redacted();   // the content with the personal data masked, when you asked
$verdict->context();    // repeats, near-duplicates, the actor's record and what it moved
$verdict->shadow();     // what a policy you are trialling would have said. Never what happened
$verdict->facts();      // noticed, not a finding: a language, an age signal, a fingerprint
$verdict->degraded();   // part of the pipeline could not run

redacted is usually worth more than a refusal: throwing a whole comment away because it carried one phone number throws away everything else the person wrote.

$verdict = $tf->text($comment, ['redact' => true, 'actor' => 'user_8812']);

publish($verdict->redacted() ?? $comment);

#Who is writing

Every verdict also carries leads: what kind of lead wrote it, scored per type. Neither a harm nor a subject, but who is on the other side and what they want.

$verdict = $tf->conversation($messages, [
    'rules' => ['leads' => ['free_work_for_equity' => ['block' => 0.6]]],
]);

$verdict->leads();                       // ['free_work_for_equity' => 0.9, 'no_budget' => 0.7]
$verdict->lead('sales_pitch');           // 0.0 when nothing of that type showed
$verdict->blocked();                     // true: your rule discarded it

Types: free_work_for_equity, no_budget, unrealistic_expectations, free_consulting, scope_creep, no_show, sales_pitch, partnership_offer, job_seeker, student_or_survey, support_request. Send the conversation rather than one message and everything that person said counts.

#Everything else

$tf->email('someone@mailinator.com');
$tf->name('asdkjhasd');
$tf->signup(['name' => 'Ana', 'email' => 'ana@example.com', 'bio' => '...']);
$tf->image('https://cdn.example.com/photo.jpg');
$tf->image($base64OrDataUri);                   // or the bytes, if you have not published it
$tf->imageData($rawFileContents);               // the same thing, said explicitly
$tf->url('https://bit.ly/3xYz');               // one link, judged as a link
$tf->prompt($whatTheUserTypedIntoYourChatbot);   // prompt injection, plus everything else

// A message with what came before it. A pile-on is thirty people each writing one
// ordinary rude sentence, and no classifier reading one of them can see it.
$tf->conversation([
    ['author' => 'u1', 'content' => '...'],
    ['author' => 'u2', 'content' => '...'],
    ['author' => 'u5', 'content' => 'the one being judged'],
], ['locales' => ['es']]);

// Many things in one call. One bad item is an item, not a batch.
$batch = $tf->batch([
    ['kind' => 'text', 'content' => '...', 'reference' => 'c_1'],
    ['kind' => 'image', 'url' => '...', 'reference' => 'p_2'],
], ['ai' => false]);

foreach ($batch->verdicts() as $index => $verdict) { /* ... */ }
foreach ($batch->failures() as $index => $error) { /* ... */ }

// A backfill: queued, answered immediately, polled or webhooked.
$queued = $tf->batchAsync($tenThousandComments);
$tf->batchStatus($queued->id());

// A backfill read back a page at a time. A cursor, not an offset: rows appear as workers
// finish them, so an offset skips whatever was inserted behind it.
$page = $tf->batchStatus($queued->id());

while ($page->hasMore()) {
    $page = $tf->batchStatus($queued->id(), ['after' => $page->nextAfter()]);
}

// The review queue.
$queue = $tf->records(['state' => 'open']);
$tf->resolve($id, 'approved', 'ana@example.com');
$tf->feedback($id, 'false_positive');   // free, and the only honest measure we have

// And what a held verdict has had done to it.
$verdict = $tf->record($id);
$verdict->reviewState();         // open, approved or rejected
$verdict->resolvedBy();          // your own name for whoever decided
$verdict->feedback();            // what you already told us, or null
$verdict->content();             // only when your policy keeps it, and only until it expires

$tf->keys();                  // prefixes, modes, last use. Never a secret.
$tf->revokeKey($id);          // including the one you are calling with. There is no create.

$tf->usage();   // credits, windows, prices. Works at zero credits.
$tf->ping();

#Webhooks

Your endpoint URL is public. Verify before you act:

use ToxicFilter\Webhooks;

$event = Webhooks::event(
    file_get_contents('php://input'),          // the RAW body
    $_SERVER['HTTP_X_TOXICFILTER_SIGNATURE'],
    getenv('TOXICFILTER_WEBHOOK_SECRET'),
);

if ($event === null) {
    http_response_code(400);
    exit;
}

#Notes

Requires PHP 8.1 and ext-curl. No other dependencies: a client for one small API is not worth a dependency tree, and pinning an HTTP library is how a client gets removed from a project. Bring your own by implementing ToxicFilter\Transport.

#How this is tested

Two suites, answering two different questions.

composer install
composer test

That one runs here, with no network and no framework, over a stub transport. It covers what a caller cannot see and would otherwise discover from an invoice: what is retried, what never is, that a retry reuses its idempotency key while two calls do not, and that bytes go out as bytes.

The second one lives in the ToxicFilter application, which keeps this package as a Composer path repository and implements the Transport interface below by dispatching straight into its own test client. Every method here then runs against the real routes, middleware and responses, so a change on either side that would break the other fails a build rather than an install.

The split is deliberate. A stub agrees with whatever it is handed, so it can prove this client behaves correctly and can never prove it agrees with the API about a field name. Fixtures alone would match the API on the day they were written and drift silently afterwards.

#Author

Created by Edu Lazaro for ToxicFilter, the moderation API this client speaks to.

#License

The ToxicFilter PHP SDK is open-sourced software licensed under the MIT license.

Nova versão disponível.