33namespace tobimori \Seo \Ai \Drivers ;
44
55use Generator ;
6- use Kirby \Exception \Exception as KirbyException ;
76use tobimori \Seo \Ai \Chunk ;
87use tobimori \Seo \Ai \Driver ;
8+ use tobimori \Seo \Ai \SseStream ;
99
1010class OpenAi extends Driver
1111{
@@ -36,10 +36,9 @@ public function stream(string $prompt, array $context = []): Generator
3636 'stream ' => true ,
3737 ];
3838
39- // Responses API accepts strings, arrays of content blocks, or message lists.
40- if (isset ($ context ['input ' ]) === true ) {
39+ if (isset ($ context ['input ' ])) {
4140 $ payload ['input ' ] = $ context ['input ' ];
42- } elseif (isset ($ context ['messages ' ]) === true ) {
41+ } elseif (isset ($ context ['messages ' ])) {
4342 $ payload ['input ' ] = $ context ['messages ' ];
4443 } else {
4544 $ payload ['input ' ] = $ input ;
@@ -53,126 +52,47 @@ public function stream(string $prompt, array $context = []): Generator
5352 $ payload ['metadata ' ] = $ context ['metadata ' ];
5453 }
5554
56- $ options = [
57- 'http ' => [
58- 'method ' => 'POST ' ,
59- 'header ' => implode ("\r\n" , $ headers ),
60- 'content ' => json_encode ($ payload , JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ),
61- 'ignore_errors ' => true ,
62- 'protocol_version ' => 1.1 ,
63- ]
64- ];
65-
66- $ contextResource = stream_context_create ($ options );
67- $ handle = @fopen ($ endpoint , 'rb ' , false , $ contextResource );
68-
69- if ($ handle === false ) {
70- throw new KirbyException ('Failed to establish OpenAI stream. ' );
71- }
55+ $ stream = new SseStream ($ endpoint , $ headers , $ payload , (int )$ this ->config ('timeout ' , 120 ));
56+ yield from $ stream ->stream (function (array $ event ): Generator {
57+ $ type = $ event ['type ' ] ?? null ;
7258
73- try {
74- $ meta = stream_get_meta_data ($ handle );
75- $ headers = $ meta ['wrapper_data ' ] ?? [];
76- $ status = $ this ->extractStatusCode ($ headers );
77-
78- if ($ status !== null && $ status >= 400 ) {
79- $ body = stream_get_contents ($ handle ) ?: '' ;
80- throw new KirbyException (sprintf (
81- 'OpenAI request failed (%d): %s ' ,
82- $ status ,
83- $ this ->summarizeBody ($ body )
84- ));
59+ if ($ type === 'response.created ' ) {
60+ yield Chunk::streamStart ($ event );
61+ return ;
8562 }
8663
87- stream_set_blocking ($ handle , true );
88- stream_set_timeout ($ handle , 60 );
89-
90- while (!feof ($ handle )) {
91- $ line = fgets ($ handle );
92-
93- if ($ line === false ) {
94- $ meta = stream_get_meta_data ($ handle );
95- if (($ meta ['timed_out ' ] ?? false ) === true ) {
96- throw new KirbyException ('OpenAI stream timed out. ' );
97- }
98-
99- break ;
100- }
101-
102- $ line = trim ($ line );
103-
104- // skip keep-alive newlines and unrelated prefixes
105- if ($ line === '' || str_starts_with ($ line , ': ' )) {
106- continue ;
107- }
108-
109- if (str_starts_with ($ line , 'data: ' ) === false ) {
110- continue ;
111- }
112-
113- $ payload = trim (substr ($ line , 5 ));
114-
115- if ($ payload === '' || $ payload === '[DONE] ' ) {
116- yield Chunk::done ();
117- break ;
118- }
119-
120- $ event = json_decode ($ payload , true );
121-
122- if (json_last_error () !== JSON_ERROR_NONE || $ event === null ) {
123- continue ;
124- }
125-
126- $ type = $ event ['type ' ] ?? null ;
127-
128- if ($ type === 'response.error ' ) {
129- $ message = $ event ['error ' ]['message ' ] ?? 'Unknown OpenAI streaming error. ' ;
130- throw new KirbyException ($ message );
131- }
132-
133- if ($ type === 'response.output_text.delta ' ) {
134- $ delta = $ event ['delta ' ] ?? '' ;
135-
136- if ($ delta !== '' ) {
137- yield Chunk::textDelta ($ delta );
138- }
139-
140- continue ;
141- }
64+ if ($ type === 'response.in_progress ' ) {
65+ yield Chunk::textStart ($ event );
66+ return ;
67+ }
14268
143- if ($ type === 'response.completed ' ) {
144- yield Chunk::done ($ event ['response ' ] ?? null );
145- break ;
69+ if ($ type === 'response.output_text.delta ' ) {
70+ $ delta = $ event ['delta ' ] ?? '' ;
71+ if ($ delta !== '' ) {
72+ yield Chunk::textDelta ($ delta , $ event );
14673 }
74+ return ;
14775 }
148- } finally {
149- fclose ($ handle );
150- }
151- }
15276
153- private function extractStatusCode (array $ headers ): int |null
154- {
155- $ statusLine = $ headers [0 ] ?? null ;
156-
157- if ($ statusLine === null ) {
158- return null ;
159- }
160-
161- if (preg_match ('/HTTP\/\d(?:\.\d)?\s+(\d{3})/ ' , $ statusLine , $ matches ) === 1 ) {
162- return (int )$ matches [1 ];
163- }
164-
165- return null ;
166- }
77+ if ($ type === 'response.output_text.done ' ) {
78+ yield Chunk::textComplete ($ event );
79+ return ;
80+ }
16781
168- private function summarizeBody (string $ body , int $ limit = 200 ): string
169- {
170- $ body = trim ($ body );
82+ if ($ type === 'response.completed ' ) {
83+ yield Chunk::streamEnd ($ event );
84+ return ;
85+ }
17186
172- if (strlen ($ body ) <= $ limit ) {
173- return $ body ;
174- }
87+ if ($ type === 'response.output_item.added ' && ($ event ['item ' ]['type ' ] ?? null ) === 'reasoning ' ) {
88+ yield Chunk::thinkingStart ($ event );
89+ return ;
90+ }
17591
176- return substr ($ body , 0 , $ limit - 3 ) . '... ' ;
92+ if ($ type === 'response.error ' ) {
93+ $ message = $ event ['error ' ]['message ' ] ?? 'Unknown OpenAI streaming error. ' ;
94+ yield Chunk::error ($ message , $ event );
95+ }
96+ });
17797 }
17898}
0 commit comments