From 2ef81c9495e311fe5b47eaba2d934ef2c9aa734a Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 13 Oct 2024 23:15:05 +0200 Subject: [PATCH 1/3] Implement REST API --- README.md | 38 ++++- composer.json | 1 + src/main/php/com/openai/rest/Api.class.php | 34 +++++ .../php/com/openai/rest/EventStream.class.php | 115 +++++++++++++++ .../com/openai/rest/OpenAIEndpoint.class.php | 21 +++ .../openai/unittest/EventStreamTest.class.php | 134 ++++++++++++++++++ 6 files changed, 339 insertions(+), 4 deletions(-) create mode 100644 src/main/php/com/openai/rest/Api.class.php create mode 100644 src/main/php/com/openai/rest/EventStream.class.php create mode 100644 src/main/php/com/openai/rest/OpenAIEndpoint.class.php create mode 100644 src/test/php/com/openai/unittest/EventStreamTest.class.php diff --git a/README.md b/README.md index e554cba..699c19d 100755 --- a/README.md +++ b/README.md @@ -28,14 +28,44 @@ $tokens= Encoding::for('omni')->load($source)->encode('Hello World!'); Completions ----------- -*Coming soon* +Using the REST API, see https://platform.openai.com/docs/api-reference/making-requests -Embeddings ----------- -*Coming soon* +```php +use util\cmd\Console; +use com\openai\rest\OpenAIEndpoint; + +$ai= new OpenAIEndpoint('https://'.getenv('OPENAI_API_KEY').'@api.openai.com/v1'); +$payload= [ + 'model' => 'gpt-4o-mini', + 'messages' => [['role' => 'user', 'content' => $prompt]], +]; + +Console::writeLine($ai->api('/chat/completions')->invoke($payload)); +``` Streaming --------- +The REST API can use server-sent events to stream responses, see https://platform.openai.com/docs/api-reference/streaming + +```php +use util\cmd\Console; +use com\openai\rest\OpenAIEndpoint; + +$ai= new OpenAIEndpoint('https://'.getenv('OPENAI_API_KEY').'@api.openai.com/v1'); +$payload= [ + 'model' => 'gpt-4o-mini', + 'messages' => [['role' => 'user', 'content' => $prompt]], +]; + +$stream= $ai->api('/chat/completions')->stream($payload); +foreach ($stream->deltas('content') as $delta) { + Console::write($delta); +} +Console::writeLine(); +``` + +Embeddings +---------- *Coming soon* Functions diff --git a/composer.json b/composer.json index 36cd4d0..4d37f73 100755 --- a/composer.json +++ b/composer.json @@ -7,6 +7,7 @@ "keywords": ["module", "xp"], "require" : { "xp-framework/core": "^12.0 | ^11.0 | ^10.0", + "xp-forge/rest-client": "^5.6", "php" : ">=7.4.0" }, "require-dev" : { diff --git a/src/main/php/com/openai/rest/Api.class.php b/src/main/php/com/openai/rest/Api.class.php new file mode 100644 index 0000000..7c70f9b --- /dev/null +++ b/src/main/php/com/openai/rest/Api.class.php @@ -0,0 +1,34 @@ +resource= $resource; + } + + /** Invokes API and returns result */ + public function invoke(array $payload) { + $r= $this->resource + ->accepting('application/json') + ->post(['stream' => false] + $payload, 'application/json') + ; + if (200 === $r->status()) return $r->value(); + + throw new UnexpectedStatus($r); + } + + /** Streams API response */ + public function stream(array $payload): EventStream { + $r= $this->resource + ->accepting('text/event-stream') + ->post(['stream' => true] + $payload, 'application/json') + ; + if (200 === $r->status()) return new EventStream($r->stream()); + + throw new UnexpectedStatus($r); + } +} \ No newline at end of file diff --git a/src/main/php/com/openai/rest/EventStream.class.php b/src/main/php/com/openai/rest/EventStream.class.php new file mode 100644 index 0000000..8f01682 --- /dev/null +++ b/src/main/php/com/openai/rest/EventStream.class.php @@ -0,0 +1,115 @@ +stream= $stream; + } + + /** + * Apply a given value with a delta + * + * @param var $result + * @param string|int $field + * @param var $delta + * @return void + * @throws lang.IllegalStateException + */ + private function apply(&$result, $field, $delta) { + if (null === $delta) { + // NOOP + } else if (is_string($delta)) { + $result[$field]??= ''; + $result[$field].= $delta; + } else if (is_int($delta) || is_float($delta)) { + $result[$field]??= 0; + $result[$field]+= $delta; + } else if (is_array($delta)) { + if (isset($delta['index'])) { + $ptr= &$result[$delta['index']]; + unset($delta['index']); + } else { + $ptr= &$result[$field]; + } + $ptr??= []; + foreach ($delta as $key => $val) { + $this->apply($ptr, $key, $val); + } + } else { + throw new IllegalStateException('Cannot apply delta '.Objects::stringOf($delta)); + } + } + + /** + * Merge a given value with the result, yielding any deltas + * + * @param var $result + * @param var $value + * @return iterable + * @throws lang.IllegalStateException + */ + private function merge(&$result, $value) { + if (is_array($value)) { + $result??= []; + foreach ($value as $key => $val) { + if ('delta' === $key) { + foreach ($val as $field => $delta) { + yield $field => $delta; + $this->apply($result['message'], $field, $delta); + } + } else { + yield from $this->merge($result[$key], $val); + } + } + } else { + $result= $value; + } + } + + /** + * Returns delta pairs while reading + * + * @throws lang.IllegalStateException + */ + public function deltas(?string $filter= null): iterable { + if (null !== $this->result) { + throw new IllegalStateException('Event stream already consumed'); + } + + $r= new StringReader($this->stream); + while (null !== ($line= $r->readLine())) { + if (!str_starts_with($line, 'data: ')) continue; + // echo "\n<<< $line\n"; + + // Last chunk is "data: [DONE]" + $data= substr($line, 6); + if ('[DONE]' === $data) break; + + // Process deltas, applying them to our result while simultaneously + // yielding them back to our caller. + foreach ($this->merge($this->result, json_decode($data, true)) as $field => $delta) { + if (null === $filter || $filter === $field) yield $field => $delta; + } + } + $this->stream->close(); + } + + /** Returns the result, fetching deltas if necessary */ + public function result(): array { + if (null === $this->result) iterator_count($this->deltas()); + return $this->result; + } +} \ No newline at end of file diff --git a/src/main/php/com/openai/rest/OpenAIEndpoint.class.php b/src/main/php/com/openai/rest/OpenAIEndpoint.class.php new file mode 100644 index 0000000..de7ae65 --- /dev/null +++ b/src/main/php/com/openai/rest/OpenAIEndpoint.class.php @@ -0,0 +1,21 @@ +endpoint= $arg instanceof Endpoint ? $arg : new Endpoint($arg); + } + + /** Returns an API */ + public function api(string $path, array $segments= []): Api { + return new Api($this->endpoint->resource(ltrim($path, '/'), $segments)); + } +} \ No newline at end of file diff --git a/src/test/php/com/openai/unittest/EventStreamTest.class.php b/src/test/php/com/openai/unittest/EventStreamTest.class.php new file mode 100644 index 0000000..0e20084 --- /dev/null +++ b/src/test/php/com/openai/unittest/EventStreamTest.class.php @@ -0,0 +1,134 @@ + $delta) { + $r[]= [$field => $delta]; + } + return $r; + } + + /** Filtered deltas */ + private function filtered(): iterable { + yield [null, [['role' => 'assistant'], ['content' => 'Test'], ['content' => 'ed']]]; + yield ['role', [['role' => 'assistant']]]; + yield ['content', [['content' => 'Test'], ['content' => 'ed']]]; + } + + #[Test] + public function can_create() { + new EventStream($this->input([])); + } + + #[Test] + public function receive_done_as_first_token() { + $events= ['data: [DONE]']; + Assert::equals([], $this->pairsOf((new EventStream($this->input($events)))->deltas())); + } + + #[Test] + public function does_not_continue_reading_after_done() { + $events= ['data: [DONE]', '', 'data: "Test"']; + Assert::equals([], $this->pairsOf((new EventStream($this->input($events)))->deltas())); + } + + #[Test] + public function deltas() { + Assert::equals( + [['role' => 'assistant'], ['content' => 'Test'], ['content' => 'ed']], + $this->pairsOf((new EventStream($this->input($this->contentStream())))->deltas()) + ); + } + + #[Test] + public function deltas_throws_if_already_consumed() { + $events= new EventStream($this->input($this->contentStream())); + iterator_count($events->deltas()); + + Assert::throws(IllegalStateException::class, fn() => iterator_count($events->deltas())); + } + + #[Test] + public function ignores_newlines() { + Assert::equals( + [['role' => 'assistant'], ['content' => 'Test'], ['content' => 'ed']], + $this->pairsOf((new EventStream($this->input(['', ...$this->contentStream()])))->deltas()) + ); + } + + #[Test, Values(from: 'filtered')] + public function filtered_deltas($filter, $expected) { + Assert::equals( + $expected, + $this->pairsOf((new EventStream($this->input($this->contentStream())))->deltas($filter)) + ); + } + + #[Test] + public function result() { + Assert::equals( + ['choices' => [['message' => ['role' => 'assistant', 'content' => 'Tested']]]], + (new EventStream($this->input($this->contentStream())))->result() + ); + } + + #[Test] + public function tool_call_deltas() { + Assert::equals( + [ + ['role' => 'assistant'], + ['tool_calls' => [['type' => 'function', 'function' => ['name' => 'search', 'arguments' => '']]]], + ['tool_calls' => [['function' => ['arguments' => '{']]]], + ['tool_calls' => [['function' => ['arguments' => '}']]]], + ], + $this->pairsOf((new EventStream($this->input($this->toolCallStream())))->deltas()) + ); + } + + #[Test] + public function tool_call_result() { + $calls= [['type' => 'function', 'function' => ['name' => 'search', 'arguments' => '{}']]]; + Assert::equals( + ['choices' => [[ + 'message' => ['role' => 'assistant', 'tool_calls' => $calls], + 'finish_reason' => 'function_call', + ]]], + (new EventStream($this->input($this->toolCallStream())))->result() + ); + } +} \ No newline at end of file From e490a4e71ebc7bb040e79ffaa0dea262e81d2f2d Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 13 Oct 2024 23:17:48 +0200 Subject: [PATCH 2/3] Fix PHP 7.4 compatibility --- src/main/php/com/openai/rest/EventStream.class.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/php/com/openai/rest/EventStream.class.php b/src/main/php/com/openai/rest/EventStream.class.php index 8f01682..bde3a4d 100644 --- a/src/main/php/com/openai/rest/EventStream.class.php +++ b/src/main/php/com/openai/rest/EventStream.class.php @@ -91,7 +91,7 @@ public function deltas(?string $filter= null): iterable { $r= new StringReader($this->stream); while (null !== ($line= $r->readLine())) { - if (!str_starts_with($line, 'data: ')) continue; + if (0 !== strncmp($line, 'data: ', 5)) continue; // echo "\n<<< $line\n"; // Last chunk is "data: [DONE]" From 2b6a7d31f18db3255e09e49f18765355f5b83c72 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 13 Oct 2024 23:23:06 +0200 Subject: [PATCH 3/3] Document simplified SSE implementation [skip ci] --- src/main/php/com/openai/rest/EventStream.class.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/php/com/openai/rest/EventStream.class.php b/src/main/php/com/openai/rest/EventStream.class.php index bde3a4d..c08814c 100644 --- a/src/main/php/com/openai/rest/EventStream.class.php +++ b/src/main/php/com/openai/rest/EventStream.class.php @@ -7,7 +7,12 @@ /** * OpenAI API event stream * + * Note: While these event streams are based on server-sent events, they do not + * utilize their full extent - there are no event types, IDs or multiline data. + * This implementation can be a bit simpler because of that. + * * @see https://platform.openai.com/docs/guides/production-best-practices/streaming + * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events * @test com.openai.unittest.EventStreamTest */ class EventStream {