Skip to content
Open
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
551 changes: 355 additions & 196 deletions Cargo.lock

Large diffs are not rendered by default.

115 changes: 115 additions & 0 deletions INSTRUCTIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# LLM PHP Extension — Developer Onboarding

## What is this?

A PHP extension written in Rust (via [ext-php-rs](https://github.com/davidcole1340/ext-php-rs)) that wraps the `octolib` crate to expose LLM functionality to PHP. Supports OpenAI, Anthropic, and other providers.

---

## Project Structure

```
src/
lib.rs — entry point, registers all PHP classes/modules
llm_class.rs — LLM main class
message.rs — Message, MessageCollection
tool_builder.rs — Tool, ToolBuilder, ToolCall
structured_builder.rs — StructuredBuilder
error.rs — PHP exception classes + Rust→PHP error mapping
convert.rs — type conversion helpers
php/
llm.php — generated IDE stubs (do not edit manually)
tests/
run_tests.php — test runner
*.php — test suites
```

---

## Build & Test

**Always use `make` — never raw `cargo build` directly** (macOS needs LLVM 17 env vars set).

```bash
make build # debug build
make release # release build
make test # build + run PHP tests
make stubs # regenerate php/llm.php stubs
make ci # full CI check locally
```

Run tests manually:
```bash
php -d 'extension=target/debug/libllm.dylib' tests/run_tests.php
```

---

## Key ext-php-rs Patterns

### Registering a plain class
```rust
#[php_class]
pub struct MyClass { ... }

#[php_impl]
impl MyClass {
pub fn __construct(...) -> Self { ... }
pub fn my_method(&self) -> String { ... }
}
```

### Exception classes that extend `\Exception`

**The tricky part.** Two rules:
1. Must have `#[php_impl]` with a `__construct` — without it ext-php-rs blocks PHP-side instantiation with "You cannot instantiate this class from PHP."
2. The Rust `__construct` **shadows** `Exception::__construct`, so the message/code are **never stored** unless you explicitly put them as `#[php(prop)]` fields on the struct.

**Correct pattern:**
```rust
#[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, so declaring it as a prop on the struct is what makes it work.

### Throwing exceptions from Rust
```rust
// In error.rs — map Rust errors to PHP exceptions
PhpException::from_class::<LLMConnectionException>("something went wrong".into())
```

Return `PhpResult<T>` from any `#[php_impl]` method and ext-php-rs will throw it automatically.

---

## Stubs (`php/llm.php`)

Generated via `make stubs`. Used for IDE autocompletion only — not loaded at runtime. Regenerate after any public API change.

---

## Common Pitfalls

| Problem | Cause | Fix |
|---|---|---|
| `cargo build` fails with `Cannot turn unknown calling convention` | Missing LLVM 17 env vars | Use `make build` |
| Exception `getMessage()` returns empty string | Rust `__construct` shadows parent, message never stored | Add `message`/`code` as `#[php(prop)]` fields |
| "You cannot instantiate this class from PHP." | No `#[php_impl]` block on the class | Add `#[php_impl]` with `__construct` |
| Stubs out of date | Forgot to regenerate after API change | `make stubs` |
40 changes: 34 additions & 6 deletions php/llm.php
Original file line number Diff line number Diff line change
Expand Up @@ -363,15 +363,43 @@ public function toJson(): string {}
public function __construct(?array $messages = null) {}
}

class LLMException {
public function __construct(string $_message, int $_code) {}
class LLMException extends \Exception {
protected $message;

protected $code;

public function __construct(?string $message = null, ?int $code = null) {}
}

class LLMConnectionException extends \Exception {
protected $code;

protected $message;

public function __construct(?string $message = null, ?int $code = null) {}
}

class LLMValidationException extends \Exception {
protected $message;

protected $code;

public function __construct(?string $message = null, ?int $code = null) {}
}

class LLMValidationException {
public function __construct(string $_message, int $_code) {}
class LLMStructuredOutputException extends \Exception {
protected $code;

protected $message;

public function __construct(?string $message = null, ?int $code = null) {}
}

class LLMStructuredOutputException {
public function __construct(string $_message, int $_code) {}
class LLMToolCallException extends \Exception {
protected $code;

protected $message;

public function __construct(?string $message = null, ?int $code = null) {}
}
}
93 changes: 33 additions & 60 deletions src/error.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
use ext_php_rs::exception::PhpException;
use ext_php_rs::php_class;
use ext_php_rs::php_impl;
use ext_php_rs::prelude::*;
use octolib::errors::{ProviderError, StructuredOutputError, ToolCallError};

/// Convert octolib errors to PHP exceptions
Expand Down Expand Up @@ -81,64 +80,38 @@ impl IntoPhpException for anyhow::Error {
}
}

// Exception classes for PHP
#[php_class]
#[php(name = "LLMException")]
pub struct LLMException;

#[php_impl]
impl LLMException {
#[php(constructor)]
pub fn __construct(_message: String, _code: i64) -> Self {
// This is just a marker, actual exception is thrown by ext-php-rs
Self
}
}

#[php_class]
#[php(name = "LLMConnectionException")]
pub struct LLMConnectionException;

#[php_impl]
impl LLMConnectionException {
#[php(constructor)]
pub fn __construct(_message: String, _code: i64) -> Self {
Self
}
}

#[php_class]
#[php(name = "LLMValidationException")]
pub struct LLMValidationException;

#[php_impl]
impl LLMValidationException {
#[php(constructor)]
pub fn __construct(_message: String, _code: i64) -> Self {
Self
}
}

#[php_class]
#[php(name = "LLMStructuredOutputException")]
pub struct LLMStructuredOutputException;
// Exception classes for PHP — all extend \Exception so they implement Throwable
// and can be caught with catch (\Throwable $e) in userland.
//
// We store message/code as #[php(prop)] fields so that Exception::getMessage()
// and Exception::getCode() work correctly. The __construct populates them;
// ext-php-rs requires a #[php_impl] block to allow PHP-side instantiation.

macro_rules! php_exception_class {
($rust_name:ident, $php_name:literal) => {
#[php_class]
#[php(name = $php_name, extends(ce = ext_php_rs::zend::ce::exception, stub = "\\Exception"))]
pub struct $rust_name {
#[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 LLMStructuredOutputException {
#[php(constructor)]
pub fn __construct(_message: String, _code: i64) -> Self {
Self
}
#[php_impl]
impl $rust_name {
pub fn __construct(message: Option<String>, code: Option<i64>) -> Self {
Self {
message: message.unwrap_or_default(),
code: code.unwrap_or(0),
}
}
}
};
}

#[php_class]
#[php(name = "LLMToolCallException")]
pub struct LLMToolCallException;

#[php_impl]
impl LLMToolCallException {
#[php(constructor)]
pub fn __construct(_message: String, _code: i64) -> Self {
Self
}
}
php_exception_class!(LLMException, "LLMException");
php_exception_class!(LLMConnectionException, "LLMConnectionException");
php_exception_class!(LLMValidationException, "LLMValidationException");
php_exception_class!(LLMStructuredOutputException, "LLMStructuredOutputException");
php_exception_class!(LLMToolCallException, "LLMToolCallException");
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
.class::<message::Message>()
.class::<message::MessageCollection>()
.class::<error::LLMException>()
.class::<error::LLMConnectionException>()
.class::<error::LLMValidationException>()
.class::<error::LLMStructuredOutputException>()
.class::<error::LLMToolCallException>()
}
2 changes: 1 addition & 1 deletion src/llm_class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ impl LLM {
&model,
self.temperature,
self.top_p,
50,
50, // top_k
self.max_tokens,
);

Expand Down
83 changes: 83 additions & 0 deletions tests/ExceptionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php
/**
* Exception class tests
*
* Verifies that all LLM exception classes:
* 1. Are registered (exist as classes)
* 2. Extend \Exception (and therefore implement \Throwable)
* 3. Can be instantiated and thrown/caught
*/

class ExceptionTest {
private static array $exceptionClasses = [
'LLMException',
'LLMConnectionException',
'LLMValidationException',
'LLMStructuredOutputException',
'LLMToolCallException',
];

public static function testAllExceptionClassesExist(): void {
foreach (self::$exceptionClasses as $class) {
TestAssert::assert(
class_exists($class),
"Class {$class} does not exist — not registered in get_module()"
);
}
}

public static function testAllExceptionClassesExtendException(): void {
foreach (self::$exceptionClasses as $class) {
TestAssert::assert(
class_exists($class),
"Class {$class} does not exist"
);
$parents = class_parents($class);
TestAssert::assert(
isset($parents['Exception']) || in_array('Exception', $parents, true),
"{$class} does not extend \\Exception — catch(\\Throwable) will not work"
);
}
}

public static function testAllExceptionClassesImplementThrowable(): void {
foreach (self::$exceptionClasses as $class) {
TestAssert::assert(
class_exists($class),
"Class {$class} does not exist"
);
$interfaces = class_implements($class);
TestAssert::assert(
isset($interfaces['Throwable']) || in_array('Throwable', $interfaces, true),
"{$class} does not implement \\Throwable"
);
}
}

public static function testExceptionClassesCanBeThrown(): void {
foreach (self::$exceptionClasses as $class) {
$caught = false;
try {
throw new $class("test message");
} catch (\Exception $e) {
$caught = true;
TestAssert::assertEquals("test message", $e->getMessage(), "{$class} message mismatch");
TestAssert::assertInstanceOf($class, $e, "Caught wrong type for {$class}");
}
TestAssert::assert($caught, "{$class} was not caught by catch(\\Exception)");
}
}

public static function testExceptionClassesCaughtAsThrowable(): void {
foreach (self::$exceptionClasses as $class) {
$caught = false;
try {
throw new $class("throwable test");
} catch (\Throwable $e) {
$caught = true;
TestAssert::assertInstanceOf($class, $e, "Caught wrong type for {$class} via Throwable");
}
TestAssert::assert($caught, "{$class} was not caught by catch(\\Throwable)");
}
}
}
8 changes: 8 additions & 0 deletions tests/run_tests.php
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,18 @@ public static function assertNull($value, string $message = ''): void {
require_once __DIR__ . '/LLMTest.php';
require_once __DIR__ . '/MessageTest.php';
require_once __DIR__ . '/ToolTest.php';
require_once __DIR__ . '/ExceptionTest.php';

// Run tests
$runner = new TestRunner();

// Exception tests
$runner->addTest('Exception classes exist', [ExceptionTest::class, 'testAllExceptionClassesExist']);
$runner->addTest('Exception classes extend \\Exception', [ExceptionTest::class, 'testAllExceptionClassesExtendException']);
$runner->addTest('Exception classes implement Throwable', [ExceptionTest::class, 'testAllExceptionClassesImplementThrowable']);
$runner->addTest('Exception classes can be thrown and caught', [ExceptionTest::class, 'testExceptionClassesCanBeThrown']);
$runner->addTest('Exception classes caught as Throwable', [ExceptionTest::class, 'testExceptionClassesCaughtAsThrowable']);

// LLM tests
$runner->addTest('LLM instantiation', function() {
$llm = new LLM('openai:gpt-4o');
Expand Down