A PHP extension written in Rust via ext-php-rs
that wraps the octolib crate (pinned to 0.36.3,
built with default-features = false, features = ["llm"] — chat only, no
embeddings/reranking/media).
The extension is a thin translation layer. All provider logic, endpoints, authentication and wire protocols live in octolib.
src/
lib.rs — module entry point, registers every PHP class
llm_class.rs — LLM, Response, Usage; option→env var mapping
message.rs — Message, MessageCollection
tool_builder.rs — Tool, ToolBuilder, ToolCall, ToolResponse
structured_builder.rs — StructuredBuilder, StructuredResponse
error.rs — PHP exception classes + octolib error → PHP exception mapping
convert.rs — Zval ↔ serde_json conversion helpers
php/
llm.php — generated IDE stubs (never edit by hand)
tests/
run_tests.php — runner; the *.php files next to it are the suites
serialization_tests.rs — Rust-side unit tests
examples/ — runnable PHP demos
Always use make, never raw cargo build. On macOS the Makefile exports the
LLVM 17 variables bindgen needs (LIBCLANG_PATH, LLVM_CONFIG_PATH, PATH
pointing at /opt/homebrew/opt/llvm@17). Without them the build fails with an
unhelpful bindgen error.
make build # debug build
make release # release build
make test # build + run PHP tests
make stubs # regenerate php/llm.php
make ci # full CI check locally
make example # run examples/quick_demo.phpRun tests manually:
php -d 'extension=target/debug/libllm.dylib' tests/run_tests.phpThe PHP tests make no network calls, so they need no API keys.
make ci skips cargo test on macOS (known bindgen/LLVM 17 issue); CI runs it
on Ubuntu.
Every call follows the same three steps:
let (provider, model) = ProviderFactory::get_provider_for_model(&self.model)?;
let params = ChatCompletionParams::new(&messages, &model, temperature, top_p, 50, max_tokens);
let response = runtime.block_on(provider.chat_completion(params))?;A tokio multi-thread runtime is created per LLM instance and shared with the
builders it spawns; every PHP-facing call is blocking.
octolib reads credentials and endpoints exclusively from env vars. The
constructor's options array is sugar over std::env::set_var:
// src/llm_class.rs
let prefix = get_env_prefix(&model); // "openai:gpt-5.4" -> "OPENAI", "kimi" -> "MOONSHOT"
std::env::set_var(format!("{prefix}_API_KEY"), api_key);
std::env::set_var(format!("{prefix}_API_URL"), base_url);Consequences worth remembering:
base_urlis a full endpoint URL, not a base. It is POSTed to verbatim.- The naive uppercasing breaks for hyphenated prefixes (
google-vertex→GOOGLE-VERTEX_API_KEY, which octolib never reads) and for providers whose env vars do not follow the pattern (amazon→AWS_BEARER_TOKEN_BEDROCK). See the gotchas table in the README. - The variable is set process-wide and persists. Two
LLMobjects for the same provider with different keys will clobber each other in a long-running worker. std::env::set_varisunsafein edition 2024 semantics and is not thread-safe. Fine for the typical single-threaded PHP request; a hazard under ZTS.
In octolib 0.36.3 the openai: and xai: prefixes speak the OpenAI Responses
API (/v1/responses); everything else OpenAI-flavoured speaks Chat Completions
via openai_compat.rs. There is no flag to downgrade openai: to Chat
Completions. The local: provider is the OpenAI-compatible Chat Completions
path and accepts any model name and any URL.
Authoritative sources when this drifts:
octolib/src/llm/factory.rs (prefix list) and the *_API_URL constants in
octolib/src/llm/providers/*.rs.
#[php_class]
pub struct MyClass { ... }
#[php_impl]
impl MyClass {
pub fn __construct(...) -> Self { ... }
pub fn my_method(&self) -> String { ... }
}Method names are converted to camelCase for PHP (get_content → getContent).
Class names are PascalCased in the stubs (LLM → Llm), which is harmless —
PHP class names are case-insensitive.
Return &mut ZendClassObject<Self> rather than Self so the same object is
returned to PHP and chaining works:
pub fn set_temperature(
self_: &mut ZendClassObject<LLM>,
temperature: f64,
) -> &mut ZendClassObject<LLM> {
self_.temperature = temperature as f32;
self_
}Two rules:
- The class needs a
#[php_impl]block with a__construct, or ext-php-rs blocks PHP-side instantiation with "You cannot instantiate this class from PHP." - The Rust
__constructshadowsException::__construct, so the message and code are never stored unless they are declared as#[php(prop)]fields.
#[php_class]
#[php(name = "LLMException", extends(ce = ext_php_rs::zend::ce::exception, stub = "\\Exception"))]
pub struct LLMException {
#[php(prop, flags = ext_php_rs::flags::PropertyFlags::Protected)]
message: String,
#[php(prop, flags = ext_php_rs::flags::PropertyFlags::Protected)]
code: i64,
}
#[php_impl]
impl LLMException {
pub fn __construct(message: Option<String>, code: Option<i64>) -> Self {
Self { message: message.unwrap_or_default(), code: code.unwrap_or(0) }
}
}Exception::getMessage() reads the message property directly, which is why
declaring it on the struct is what makes it work. All five exception classes are
generated by the php_exception_class! macro in src/error.rs.
Return PhpResult<T> from any #[php_impl] method; ext-php-rs throws it.
octolib errors are mapped in src/error.rs via the IntoPhpException trait —
ProviderError::NetworkError/ApiError/TimeoutError → LLMConnectionException,
ModelNotSupported → LLMValidationException, and so on.
Generated by make stubs (cargo php stubs). IDE autocompletion only — never
loaded at runtime, and the test runner deliberately does not include it. Regenerate
after any public API change; CI regenerates them too.
Things a reader might expect to work but that are not implemented:
| Gap | Where |
|---|---|
StructuredResponse::getStructured() always returns null |
structured_builder.rs — blocked on a Zval cloning issue in ext-php-rs 0.15.x |
ToolBuilder::setAutoExecute() is a no-op |
auto_execute is stored and never read |
StructuredBuilder::withFormat() is a no-op |
format is stored and never read |
setFrequencyPenalty() / setPresencePenalty() never reach the provider |
not passed to ChatCompletionParams |
No timeout option |
octolib's timeout/retry params are not exposed |
StructuredResponse::toArray() / toJson() flatten nested data |
only top-level scalars survive; nested arrays are silently dropped |
No streaming, thinking blocks, caching, cost, vision, or tool_choice |
octolib supports these; the extension does not surface them |
| Problem | Cause | Fix |
|---|---|---|
cargo build fails with Cannot turn unknown calling convention |
missing LLVM 17 env vars | use make build; brew install llvm@17 |
Exception getMessage() returns an empty string |
Rust __construct shadows the parent |
add message/code as #[php(prop)] fields |
| "You cannot instantiate this class from PHP." | no #[php_impl] block on the class |
add one with __construct |
| Stubs out of date | forgot to regenerate after an API change | make stubs |
base_url appears to be ignored |
hyphenated or non-standard provider prefix | set the real env var with putenv() |
A 400 from an OpenAI-compatible server on openai: |
openai: speaks the Responses API |
use the local: prefix instead |
Only variables should be passed by reference |
Tool::__construct($parameters) and MessageCollection::add($message) take &mut args |
assign to a variable before passing |