Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

A typed PHP client for a small Python service that runs bounded AI tasks (rerank, embed, redact) on a
pool of warm child processes, one job per child at a time, with a FrankenPHP worker-mode example and a stdio MCP server as a second client of the
same HTTP protocol. Experimental: jobs live in memory, nothing is durable, no production claim is made.
same HTTP protocol. Since 0.4.0 it is supported as an internal service for applications that own their host and network
(one or more instances, each answering its own jobs, behind the operator's TLS and the application's own authentication;
the job table survives a restart with `BRIDGE_STATE_DIR`), not for public exposure; see `docs/operations.md`.
README has the overview; `docs/protocol.md`, `docs/integration.md`, `docs/mcp.md`, `docs/operations.md`, `docs/security.md`
and `docs/testing.md` are the guides. Everything builds, tests and downloads inside `docker/compose.yaml`.

Expand Down
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ All notable changes to this project are documented in this file. The format foll

## [Unreleased]

## [0.4.0] - 2026-10-26

### Added

- A graceful drain: on SIGTERM or SIGINT the worker answers `POST /v1/jobs` with 503 `draining` and `Retry-After: 1`, fails queued jobs as `service_restarted` at once, keeps answering `GET`, and lets running jobs finish for up to `BRIDGE_DRAIN_SECONDS` (integer 0 to 300, default 30) before it stops; a second signal stops it at once.
Expand Down Expand Up @@ -72,7 +74,8 @@ First release: a typed PHP client for bounded Python AI tasks (rerank, embed, re
- Trusted services only: one bearer token authorizes every job, so the application must authenticate users and own the mapping from job IDs to their owners.
- Private HTTP transport: deploy behind an authenticated TLS boundary; the example routes have no end-user authentication and publish no ports.

[Unreleased]: https://github.com/cristiangirlea/php-python-ai-bridge/compare/v0.3.0...HEAD
[Unreleased]: https://github.com/cristiangirlea/php-python-ai-bridge/compare/v0.4.0...HEAD
[0.4.0]: https://github.com/cristiangirlea/php-python-ai-bridge/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/cristiangirlea/php-python-ai-bridge/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/cristiangirlea/php-python-ai-bridge/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/cristiangirlea/php-python-ai-bridge/releases/tag/v0.1.0
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

A framework-independent PHP client and a small Python task service. Submit document reranking from PHP, poll progress, receive typed results, and cancel work without keeping the original HTTP request open.

Includes a FrankenPHP worker-mode example, deterministic fault tests, and an optional real ONNX reranker. No hosted AI account is required. This is an **experimental prototype**, not a production job queue.
Includes a FrankenPHP worker-mode example, deterministic fault tests, and an optional real ONNX reranker. No hosted AI account is required.

Since 0.4.0 it is supported as an internal service for applications that own their host and network: one or more worker instances, each answering only the jobs it took, on a private network behind the operator's TLS and the application's own authentication, with the job table kept across a restart by `BRIDGE_STATE_DIR`. It is not for exposing the worker publicly, nor for throughput beyond the measured figures (one ONNX child on one CPU served about 3.5 jobs/s of the rerank, embed and redact mix, and an instance admits at most `BRIDGE_CAPACITY` jobs per `BRIDGE_RETENTION_SECONDS`) without more instances; [the operations guide](docs/operations.md) has the topologies and the sizing.

## What is implemented

Expand Down Expand Up @@ -111,7 +113,7 @@ The downloaded cache remains for reuse; it is not included in Git.
See the [integration guide](docs/integration.md) for Composer setup, the Laravel service provider and the Symfony bundle that configure the client from `BRIDGE_URL` and `BRIDGE_TOKEN`, application job ownership and failure handling.
The client sends through `ext-curl` by default; an application with a PSR-18 client such as Symfony HttpClient or Guzzle can pass `PhpAiBridge\Psr18Transport` instead, configured as the [integration guide](docs/integration.md#through-a-psr-18-http-client) shows.

This source tree is a Composer library with PSR-4 autoloading under `PhpAiBridge\`. The latest tagged release is `v0.3.0` ([changelog](CHANGELOG.md)): install it with `composer require cristiangirlea/php-python-ai-bridge:^0.3` once the package is on Packagist, or from a VCS repository pinned to the tag until then, as the integration guide shows. `examples/bootstrap.php` is a repository-only autoloader for dependency-free examples, not an application installation method.
This source tree is a Composer library with PSR-4 autoloading under `PhpAiBridge\`. The latest tagged release is `v0.4.0` ([changelog](CHANGELOG.md)): install it with `composer require cristiangirlea/php-python-ai-bridge:^0.4` once the package is on Packagist, or from a VCS repository pinned to the tag until then, as the integration guide shows. `examples/bootstrap.php` is a repository-only autoloader for dependency-free examples, not an application installation method.

```php
use PhpAiBridge\Client;
Expand Down
4 changes: 2 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
## Supported versions

Only the latest minor release receives security fixes. Older releases are not patched; upgrade to the latest
one first. This project is experimental: see [the security boundaries](docs/security.md) for what it does and
does not protect.
one first. See [the security boundaries](docs/security.md) for what the service does and
does not protect, and [the operations guide](docs/operations.md) for the supported deployment tier.

## Reporting a vulnerability

Expand Down
6 changes: 3 additions & 3 deletions docs/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ Three normal tasks are implemented. Reranking: given a query and up to 512 candi

## Install through Composer

The latest tagged release is `v0.3.0`; see [the changelog](../CHANGELOG.md). Once the package is on Packagist, this is the normal path, run inside your application's dependency-acquisition container:
The latest tagged release is `v0.4.0`; see [the changelog](../CHANGELOG.md). Once the package is on Packagist, this is the normal path, run inside your application's dependency-acquisition container:

```sh
composer require cristiangirlea/php-python-ai-bridge:^0.3 --no-scripts --no-plugins
composer require cristiangirlea/php-python-ai-bridge:^0.4 --no-scripts --no-plugins
```

Until the Packagist listing exists, install the same tag from the repository instead. In your application's root `composer.json`, add this repository and requirement, preserving existing entries:
Expand All @@ -35,7 +35,7 @@ Until the Packagist listing exists, install the same tag from the repository ins
}
],
"require": {
"cristiangirlea/php-python-ai-bridge": "0.3.0"
"cristiangirlea/php-python-ai-bridge": "0.4.0"
}
}
```
Expand Down
6 changes: 3 additions & 3 deletions docs/operations.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Operations

How to deploy, size, upgrade and watch the worker. The [protocol](protocol.md) is the contract and the [security boundaries](security.md) say what the service protects; this guide links to them rather than repeating them. Every figure below comes from the measured tables in [the testing guide](testing.md), and every setting from `worker/ai_bridge/server.py` (`main()`) or `worker/ai_bridge/jobs.py`. The service is an experimental prototype, not a durable job queue.
How to deploy, size, upgrade and watch the worker. The [protocol](protocol.md) is the contract and the [security boundaries](security.md) say what the service protects; this guide links to them rather than repeating them. Every figure below comes from the measured tables in [the testing guide](testing.md), and every setting from `worker/ai_bridge/server.py` (`main()`) or `worker/ai_bridge/jobs.py`. The service keeps results, not work: a restart answers retained results and reports unfinished jobs, it never replays them, so it is not a durable job queue.

Some of what follows is on `main`, unreleased (under Unreleased in [CHANGELOG.md](../CHANGELOG.md), planned for the next release, 0.4.0): the drain and `BRIDGE_DRAIN_SECONDS`, `GET /readyz`, `BRIDGE_OWNER_CAPACITY`, the 503 `service_busy` and 431 answers, and `BRIDGE_LOG`.
Some of what follows is available since 0.4.0 ([CHANGELOG.md](../CHANGELOG.md)): the drain and `BRIDGE_DRAIN_SECONDS`, `GET /readyz`, `BRIDGE_OWNER_CAPACITY`, the 503 `service_busy` and 431 answers, and `BRIDGE_LOG`.

## 1. Deployment topologies

Expand Down Expand Up @@ -101,7 +101,7 @@ Images are tagged with the version without its `v` (`0.3.0`). A stable release a
| Readiness | `/readyz`, and `bridge_pool_ready` against `bridge_pool_size` | Not ready for longer than a model load, or ready children stay below the pool size |
| p95 job duration | `histogram_quantile(0.95, sum by (le, task) (rate(bridge_job_duration_seconds_bucket[5m])))` | It nears the clients' `timeout_ms` |

`bridge_job_queue_seconds` separates waiting from running, and `bridge_jobs_total{status="timed_out"}` counts deadlines. Structured logs (on `main`, unreleased): `BRIDGE_LOG=json` writes one JSON line on stderr per request and per finished job, plus start, drain and stop, with a fixed schema that never holds inputs, results, headers, tokens or exception text; off by default. Logging is best effort: lines are written behind a bounded queue and are dropped when it is full or at exit, so a log is no audit record and no substitute for `/metrics`. [The protocol](protocol.md#logging) has the schema.
`bridge_job_queue_seconds` separates waiting from running, and `bridge_jobs_total{status="timed_out"}` counts deadlines. Structured logs (since 0.4.0): `BRIDGE_LOG=json` writes one JSON line on stderr per request and per finished job, plus start, drain and stop, with a fixed schema that never holds inputs, results, headers, tokens or exception text; off by default. Logging is best effort: lines are written behind a bounded queue and are dropped when it is full or at exit, so a log is no audit record and no substitute for `/metrics`. [The protocol](protocol.md#logging) has the schema.

## 7. Failure modes

Expand Down
2 changes: 1 addition & 1 deletion docs/security.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Security boundaries

This experimental service is designed for trusted application-to-service traffic on a private network. Do not expose the example routes or Python server directly to the internet. Across hosts, put a TLS proxy in front of the worker as [Cross-host use behind TLS](integration.md#cross-host-use-behind-tls) shows.
This service is designed for trusted application-to-service traffic on a private network. Do not expose the example routes or Python server directly to the internet. Across hosts, put a TLS proxy in front of the worker as [Cross-host use behind TLS](integration.md#cross-host-use-behind-tls) shows.

- Generate a service token of at least 32 non-whitespace ASCII characters. Never commit it. A token reaches only the jobs it submitted: another token's job answers 404 as if it did not exist, and idempotency keys are per token. Give each application, and the MCP server, its own token in `BRIDGE_TOKENS` or `BRIDGE_TOKENS_FILE` (mode 0600 or stricter), so one can be revoked by removing its entry and restarting the worker without touching the others, and a leaked token exposes only its own client's jobs. Capacity is shared, so by default any token can fill the worker and starve the others; set `BRIDGE_OWNER_CAPACITY` below `BRIDGE_CAPACITY` to cap one token's queued, running and retained jobs, which is off by default. A token at its cap gets the same 429 as a full worker, so it learns nothing of the other tokens' load; the cap is the same for every token, so where it times the number of tokens exceeds the capacity the tokens together can still fill the worker: set it at most the capacity divided by the number of tokens where every token must always find room; `/healthz`, `/readyz` and `/metrics` answer every token, and the metrics carry the token names, never the tokens. The job table stores a job's owner by name only.
- Enforce end-user authentication and per-job ownership in the PHP application. The example deliberately omits those application-specific policies and publishes no ports.
Expand Down
6 changes: 3 additions & 3 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ The original prototype was not entirely developed with TDD. Review corrections u

- No measured line/branch coverage percentage, formal security audit or production certification.
- No full Symfony application (FrameworkBundle, Flex, Messenger, Runtime) or Laravel skeleton/Octane/queue test, no multi-user authorization test, and no framework run on PHP 8.2, 8.3 or 8.4 or on another framework version than the locked one.
- No Packagist release yet, so no install from Packagist has been exercised, nor one from the `v0.3.0` tag through a VCS repository: the consumer Composer-install check installs the library's `git archive` zip from an `artifact` repository inside its container, offline (falling back to a path repository in a git worktree), and the repository's own examples use a small local autoloader.
- No Packagist release yet, so no install from Packagist has been exercised, nor one from the `v0.4.0` tag through a VCS repository: the consumer Composer-install check installs the library's `git archive` zip from an `artifact` repository inside its container, offline (falling back to a path repository in a git worktree), and the repository's own examples use a small local autoloader.
- No throughput or tail-latency figure beyond one run per row on one host, with the client and the worker on the same machine and no PHP client or FrankenPHP in the path, under [Measured throughput and latency under load](#measured-throughput-and-latency-under-load-informational); no measured memory-per-job limit or CPU cost per inference. The other timing figures are the medians of sequential jobs below, warm after one cold first job. The peak of a child holding all four ONNX models during the worst-case rerank batch is measured once, on one machine, under [Measured model worker memory](#measured-model-worker-memory-informational); the model worker's 1536 MiB is a limit, which grows with `BRIDGE_CONCURRENCY` children each holding its own copies.
- Ranking quality is measured on one public dataset, ArguAna, a task unlike the reranker's training; none on short-query search and none in another language. NER quality on real data is one public set, WNUT-17's English social media text, with four of its six types merged into three labels; no other domain or language, and no GPU benchmark. The invented-sample NER figures below come from 48 sentences by the same author as the development set used to tune the redact task.
- No repeated or semantic LLM evaluation. The one recorded run below is a single session per question per model on the demo worker, so it shows that the tools can be used, not a success rate or retrieval quality.
Expand All @@ -104,7 +104,7 @@ The original prototype was not entirely developed with TDD. Review corrections u
- No verified native Windows/macOS service execution or ARM runtime; Docker tests target Linux amd64/Python 3.12.
- No durable delivery, replay or automatic retry, and no multi-instance result routing. With `BRIDGE_STATE_DIR` set the job table (never inputs) survives a restart, so retained results and idempotency keys are answered and unfinished jobs are reported as `service_restarted`; without it, jobs and results live in memory.

Configured resources are **limits, not measurements**: ordinary services, including the MCP server's own `mcp-worker`, get one CPU and 512 MiB each; the model worker gets one CPU and 1536 MiB. Normal task concurrency defaults to two, total capacity to 128 and terminal retention to 300 seconds; the MCP workers keep finished jobs for 30 seconds, because their one client reads each result as its job ends. Every worker keeps as many child processes as its concurrency and replaces one after a cancellation, a deadline, a crash or `BRIDGE_WORKER_MAX_JOBS` jobs (default 1000). The model profile sets concurrency to one, so its one child loads each model on its first job of that kind and keeps it, and scores rerank documents 32 pairs per forward pass. None of these imply 128 simultaneous inference jobs or a specific requests-per-second rate.
Configured resources are **limits, not measurements**: ordinary services, including the MCP server's own `mcp-worker`, get one CPU and 512 MiB each; the model worker gets one CPU and 1536 MiB. Normal task concurrency defaults to two, total capacity to 128 and terminal retention to 300 seconds; the MCP workers keep finished jobs for 30 seconds, because their one client reads each result as its job ends. Every worker keeps as many child processes as its concurrency and replaces one after a cancellation, a deadline, a crash or `BRIDGE_WORKER_MAX_JOBS` jobs (default 1000). The model profile sets concurrency to one; its one child loads every model it needs when it starts, before it reports ready, so the first job pays no load, and it keeps them and scores rerank documents 32 pairs per forward pass. None of these imply 128 simultaneous inference jobs or a specific requests-per-second rate.

## Measured latency (informational)

Expand Down Expand Up @@ -382,4 +382,4 @@ The semantic evaluation, `tests/mcp/evaluation-semantic.xml`, ran on 2026-09-30

On the demo worker, Haiku found all six search answers by rewording its queries toward the notes' words, so the agent supplied the meaning the backend lacks; it missed the rerank, the similarity ranking, the redaction and the backend question, which word matching cannot answer. The first Sonnet run on the models, started right after Haiku's, scored 5/10: every search it lost ended in HTTP 429, because the model worker counts finished jobs against its capacity of 128 for 300 seconds and Haiku's run had filled it. The tools then passed that refusal to the agent rather than waiting it out, and the row above is a second run after the worker was restarted. Since then a tool waits out a full worker within its own call, and the MCP workers keep finished jobs for 30 seconds rather than 300: the same sequence, Haiku then Sonnet on the models with no restart between them, scored 10/10 for each. That run made 22 calls in all, too few to fill the worker, so it shows the sequence works; `tests/mcp/test_capacity.py` shows the wait itself.

Before calling this production-ready, test the actual application/framework combinations, measure latency under sustained load on the deployment's own hardware and workloads (the load section above is one development machine), exercise overload/restarts, validate tenant isolation, and decide whether the in-memory lifecycle is acceptable. The current result is an experimental, testable integration—not a replacement for a durable job system.
Before deploying, test the actual application and framework combination, measure latency under sustained load on the deployment's own hardware and workloads (the load section above is one development machine), exercise overload and restarts, validate tenant isolation with your own tokens, and decide whether results kept across a restart without replay of unfinished work is acceptable. The supported tier is the one [the operations guide](operations.md) describes; this is not a replacement for a durable job system.
Loading