Skip to main content

netget/server/openai/
actions.rs

1//! OpenAI protocol actions implementation
2
3use crate::llm::actions::{
4    protocol_trait::{ActionResult, Protocol, Server},
5    ActionDefinition, Parameter,
6};
7use crate::protocol::EventType;
8use crate::state::app_state::AppState;
9use anyhow::{Context, Result};
10use serde_json::json;
11use std::sync::LazyLock;
12use tracing::debug;
13
14use crate::protocol::log_template::LogTemplate;
15
16/// OpenAI request event (for /v1/models, /v1/chat/completions, etc.)
17pub static OPENAI_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
18    EventType::new(
19        "openai_request",
20        "An OpenAI-compatible API request arrived. Answer with exactly one action chosen by \
21         'path': /v1/chat/completions -> openai_chat_response, /v1/models -> \
22         openai_models_response. Any request may instead be refused with openai_error_response.",
23        json!({
24            "type": "openai_chat_response",
25            "content": "Hello! How can I help you today?",
26            "model": "gpt-4"
27        }),
28    )
29    .with_alternative_example(json!({
30        "type": "openai_models_response",
31        "models": ["gpt-4", "gpt-3.5-turbo"]
32    }))
33    .with_alternative_example(json!({
34        "type": "openai_error_response",
35        "message": "Unsupported endpoint",
36        "error_type": "invalid_request_error",
37        "status": 404
38    }))
39    // This is the protocol's only event, so without an action list `call_llm` would offer the
40    // model nothing but set_memory/show_message and reject every response it produced. All three
41    // sync actions apply here: the event covers every path, and any of them may be refused.
42    .with_actions(vec![
43        openai_chat_response_action(),
44        openai_models_response_action(),
45        openai_error_response_action(),
46    ])
47    .with_parameters(vec![
48        Parameter {
49            name: "method".to_string(),
50            type_hint: "string".to_string(),
51            description: "HTTP method (GET, POST)".to_string(),
52            required: true,
53        },
54        Parameter {
55            name: "path".to_string(),
56            type_hint: "string".to_string(),
57            description: "Request path (/v1/models, /v1/chat/completions)".to_string(),
58            required: true,
59        },
60        Parameter {
61            name: "body".to_string(),
62            type_hint: "string".to_string(),
63            description: "Request body (JSON string for POST requests)".to_string(),
64            required: false,
65        },
66    ])
67});
68
69/// OpenAI protocol action handler
70pub struct OpenAiProtocol {}
71
72impl Default for OpenAiProtocol {
73    fn default() -> Self {
74        Self::new()
75    }
76}
77
78impl OpenAiProtocol {
79    pub fn new() -> Self {
80        Self {}
81    }
82}
83
84// Implement Protocol trait (common functionality)
85impl Protocol for OpenAiProtocol {
86    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
87        vec![
88            crate::llm::actions::ParameterDefinition {
89                name: "first_byte_timeout_secs".to_string(),
90                type_hint: "number".to_string(),
91                description:
92                    "Seconds a connected peer may send no request at all before the server \
93                     closes it. Default 30: HTTP is client-speaks-first, and NetGet's own \
94                     OpenAI client opens no socket until it has a request to send, so nothing \
95                     here is ever connected and waiting on a person."
96                        .to_string(),
97                required: false,
98                example: json!(30),
99                default: Some(serde_json::json!(super::FIRST_BYTE_READ_TIMEOUT.as_secs())),
100            },
101            crate::llm::actions::ParameterDefinition {
102                name: "idle_timeout_secs".to_string(),
103                type_hint: "number".to_string(),
104                description:
105                    "Seconds an established connection may sit silent between requests before \
106                     the server closes it. Default 300, chosen to stay above the 90 seconds \
107                     reqwest and most OpenAI SDKs keep an idle pooled connection: a shorter \
108                     bound races the pool and loses a non-idempotent completion request."
109                        .to_string(),
110                required: false,
111                example: json!(300),
112                default: Some(serde_json::json!(
113                    super::IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
114                )),
115            },
116        ]
117    }
118    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
119        vec![list_active_chats_action()]
120    }
121    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
122        vec![
123            openai_chat_response_action(),
124            openai_models_response_action(),
125            openai_error_response_action(),
126        ]
127    }
128    fn protocol_name(&self) -> &'static str {
129        "OpenAI"
130    }
131    fn get_event_types(&self) -> Vec<EventType> {
132        get_openai_event_types()
133    }
134    fn stack_name(&self) -> &'static str {
135        "ETH>IP>TCP>HTTP>OPENAI"
136    }
137    fn keywords(&self) -> Vec<&'static str> {
138        vec!["openai"]
139    }
140    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
141        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
142
143        ProtocolMetadataV2::builder()
144            .state(DevelopmentState::Beta)
145            .implementation("hyper with OpenAI-compatible HTTP endpoints")
146            .llm_control("Event/action system - LLM generates OpenAI responses")
147            .e2e_testing(
148                "tests/server/openai/e2e_test.rs, 10 LLM calls. Verified against the real \
149                 async-openai 0.26 Rust client: models().list(), chat().create() and the 404 \
150                 path, all deserialized into the SDK's own types (a malformed error body would \
151                 surface as a transport failure, not OpenAIError::ApiError). reqwest covers the \
152                 raw JSON envelope the SDK hides. No Python SDK is involved - an earlier version \
153                 of this field claimed one. Streaming, function calling and embeddings are \
154                 untested.",
155            )
156            .notes("OpenAI-compatible API with LLM-driven responses")
157            .max_inbound_bytes(crate::server::openai::MAX_REQUEST_BODY_BYTES)
158            .request_only("The OpenAI API is HTTP request/response; a server cannot send a peer anything unprompted")
159            .build()
160    }
161    fn description(&self) -> &'static str {
162        "OpenAI-compatible API server"
163    }
164    fn example_prompt(&self) -> &'static str {
165        "Start an OpenAI-compatible API server on port 11435"
166    }
167    fn group_name(&self) -> &'static str {
168        "AI & API"
169    }
170    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
171        use crate::llm::actions::StartupExamples;
172
173        StartupExamples::new(
174            // LLM mode: instruction-based
175            json!({
176                "type": "open_server",
177                "port": 11434,
178                "base_stack": "openai",
179                "instruction": "OpenAI-compatible API server. Respond to chat completions with helpful responses and list available models on /v1/models"
180            }),
181            // Script mode: event_handlers with script handler
182            json!({
183                "type": "open_server",
184                "port": 11434,
185                "base_stack": "openai",
186                "event_handlers": [{
187                    "event_pattern": "openai_request",
188                    "handler": {
189                        "type": "script",
190                        "language": "python",
191                        "code": "if event['path'] == '/v1/models':\n    action('openai_models_response', models=['gpt-4', 'gpt-3.5-turbo'])\nelse:\n    action('openai_chat_response', content='Hello from script!', model='gpt-4')"
192                    }
193                }]
194            }),
195            // Static mode: event_handlers with static actions
196            json!({
197                "type": "open_server",
198                "port": 11434,
199                "base_stack": "openai",
200                "event_handlers": [{
201                    "event_pattern": "openai_request",
202                    "handler": {
203                        "type": "static",
204                        "actions": [{
205                            "type": "openai_chat_response",
206                            "content": "I am a static OpenAI response",
207                            "model": "gpt-3.5-turbo"
208                        }]
209                    }
210                }]
211            }),
212        )
213    }
214}
215
216// Implement Server trait (server-specific functionality)
217impl Server for OpenAiProtocol {
218    fn spawn(
219        &self,
220        ctx: crate::protocol::SpawnContext,
221    ) -> std::pin::Pin<
222        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
223    > {
224        Box::pin(async move {
225            use crate::server::openai::OpenAiServer;
226
227            // Both read deadlines are the operator's to choose: who is on the other end is
228            // the only thing that decides them, and only the operator knows that.
229            let secs = |name: &str| -> anyhow::Result<Option<u64>> {
230                Ok(ctx
231                    .startup_params
232                    .as_ref()
233                    .map(|p| p.get_optional_u64(name))
234                    .transpose()?
235                    .flatten())
236            };
237            let first_byte_timeout_secs = secs("first_byte_timeout_secs")?;
238            let idle_timeout_secs = secs("idle_timeout_secs")?;
239
240            OpenAiServer::spawn_with_llm_actions(
241                ctx.legacy_listen_addr(),
242                ctx.llm_client,
243                ctx.state,
244                ctx.status_tx,
245                false,
246                ctx.server_id,
247                first_byte_timeout_secs,
248                idle_timeout_secs,
249            )
250            .await
251        })
252    }
253    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
254        let action_type = action
255            .get("type")
256            .and_then(|v| v.as_str())
257            .context("Missing 'type' field in action")?;
258
259        match action_type {
260            "openai_chat_response" => self.execute_openai_chat_response(action),
261            "openai_models_response" => self.execute_openai_models_response(action),
262            "openai_error_response" => self.execute_openai_error_response(action),
263            "list_active_chats" => self.execute_list_active_chats(action),
264            _ => Err(anyhow::anyhow!("Unknown OpenAI action: {}", action_type)),
265        }
266    }
267}
268
269impl OpenAiProtocol {
270    fn execute_openai_chat_response(&self, action: serde_json::Value) -> Result<ActionResult> {
271        let content = action
272            .get("content")
273            .and_then(|v| v.as_str())
274            .context("Missing 'content' field")?;
275
276        let model = action
277            .get("model")
278            .and_then(|v| v.as_str())
279            .unwrap_or("unknown");
280
281        debug!(
282            "OpenAI chat response: {} chars, model={}",
283            content.len(),
284            model
285        );
286
287        // Build OpenAI-compatible chat completion response
288        let completion_id = format!("chatcmpl-{}", chrono::Utc::now().timestamp());
289        let created = crate::utils::clock::SystemTime::now()
290            .duration_since(crate::utils::clock::UNIX_EPOCH)
291            .unwrap()
292            .as_secs();
293
294        let response = json!({
295            "id": completion_id,
296            "object": "chat.completion",
297            "created": created,
298            "model": model,
299            "choices": [{
300                "index": 0,
301                "message": {
302                    "role": "assistant",
303                    "content": content
304                },
305                "finish_reason": "stop"
306            }],
307            "usage": {
308                "prompt_tokens": 0,
309                "completion_tokens": 0,
310                "total_tokens": 0
311            }
312        });
313
314        Ok(ActionResult::Custom {
315            name: "openai_response".to_string(),
316            data: json!({
317                "status": 200,
318                "headers": vec![("Content-Type".to_string(), "application/json".to_string())],
319                "body": response.to_string()
320            }),
321        })
322    }
323
324    fn execute_openai_models_response(&self, action: serde_json::Value) -> Result<ActionResult> {
325        let models = action
326            .get("models")
327            .and_then(|v| v.as_array())
328            .context("Missing 'models' array")?;
329
330        debug!("OpenAI models response: {} models", models.len());
331
332        // Convert to OpenAI format
333        let openai_models: Vec<serde_json::Value> = models
334            .iter()
335            .map(|model_name| {
336                let name = model_name.as_str().unwrap_or("unknown");
337                json!({
338                    "id": name,
339                    "object": "model",
340                    "created": 1686935002,
341                    "owned_by": "ollama"
342                })
343            })
344            .collect();
345
346        let response = json!({
347            "object": "list",
348            "data": openai_models
349        });
350
351        Ok(ActionResult::Custom {
352            name: "openai_response".to_string(),
353            data: json!({
354                "status": 200,
355                "headers": vec![("Content-Type".to_string(), "application/json".to_string())],
356                "body": response.to_string()
357            }),
358        })
359    }
360
361    fn execute_openai_error_response(&self, action: serde_json::Value) -> Result<ActionResult> {
362        let message = action
363            .get("message")
364            .and_then(|v| v.as_str())
365            .unwrap_or("An error occurred");
366
367        let error_type = action
368            .get("error_type")
369            .and_then(|v| v.as_str())
370            .unwrap_or("server_error");
371
372        // `as u16` wraps: `status: 65736` is 200, and this action is the model's only way to
373        // return an OpenAI-shaped error. The client then reads a 200 whose body happens to
374        // carry an `error` object, which an OpenAI SDK treats as a successful completion with
375        // unexpected fields. Refuse, rather than clamp to something else it did not ask for.
376        let status = match action.get("status") {
377            None => 500,
378            Some(v) if v.is_null() => 500,
379            Some(v) => {
380                let raw = v.as_u64().ok_or_else(|| {
381                    anyhow::anyhow!("send_openai_error 'status' must be a number, got {v}")
382                })?;
383                if !(100..=599).contains(&raw) {
384                    return Err(anyhow::anyhow!(
385                        "send_openai_error 'status' {raw} is not an HTTP status code; use \
386                         100-599 (400 invalid request, 401 bad key, 429 rate limited, 500 \
387                         server error)"
388                    ));
389                }
390                raw as u16
391            }
392        };
393
394        debug!("OpenAI error response: {} ({})", message, status);
395
396        let response = json!({
397            "error": {
398                "message": message,
399                "type": error_type,
400                "code": "error"
401            }
402        });
403
404        Ok(ActionResult::Custom {
405            name: "openai_response".to_string(),
406            data: json!({
407                "status": status,
408                "headers": vec![("Content-Type".to_string(), "application/json".to_string())],
409                "body": response.to_string()
410            }),
411        })
412    }
413
414    fn execute_list_active_chats(&self, _action: serde_json::Value) -> Result<ActionResult> {
415        debug!("OpenAI list active chats");
416
417        // This is a placeholder - in a real implementation, we'd track chat sessions
418        Ok(ActionResult::Custom {
419            name: "list_active_chats".to_string(),
420            data: json!({"chats": []}),
421        })
422    }
423}
424
425/// Action definition: Send OpenAI chat completion response
426pub fn openai_chat_response_action() -> ActionDefinition {
427    ActionDefinition {
428        name: "openai_chat_response".to_string(),
429        description: "Send a chat completion response in OpenAI format".to_string(),
430        parameters: vec![
431            Parameter {
432                name: "content".to_string(),
433                type_hint: "string".to_string(),
434                description: "The assistant's response text".to_string(),
435                required: true,
436            },
437            Parameter {
438                name: "model".to_string(),
439                type_hint: "string".to_string(),
440                description: "Model name (e.g., 'gpt-3.5-turbo')".to_string(),
441                required: false,
442            },
443        ],
444        example: json!({
445            "type": "openai_chat_response",
446            "content": "Hello! How can I help you today?",
447            "model": "gpt-3.5-turbo"
448        }),
449        log_template: Some(
450            LogTemplate::new()
451                .with_info("-> OpenAI chat {model} ({content_len} chars)")
452                .with_debug("OpenAI openai_chat_response: model={model} content_len={content_len}"),
453        ),
454    }
455}
456
457/// Action definition: Send models list response
458pub fn openai_models_response_action() -> ActionDefinition {
459    ActionDefinition {
460        name: "openai_models_response".to_string(),
461        description: "Respond to GET /v1/models with available models".to_string(),
462        parameters: vec![Parameter {
463            name: "models".to_string(),
464            type_hint: "array".to_string(),
465            description: "Array of model names (e.g., ['gpt-3.5-turbo', 'gpt-4'])".to_string(),
466            required: true,
467        }],
468        example: json!({
469            "type": "openai_models_response",
470            "models": ["gpt-3.5-turbo", "gpt-4"]
471        }),
472        log_template: Some(
473            LogTemplate::new()
474                .with_info("-> OpenAI models ({models_len} available)")
475                .with_debug("OpenAI openai_models_response: models_count={models_len}"),
476        ),
477    }
478}
479
480/// Action definition: Send error response
481pub fn openai_error_response_action() -> ActionDefinition {
482    ActionDefinition {
483        name: "openai_error_response".to_string(),
484        description: "Send an error response in OpenAI format".to_string(),
485        parameters: vec![
486            Parameter {
487                name: "message".to_string(),
488                type_hint: "string".to_string(),
489                description: "Error message".to_string(),
490                required: true,
491            },
492            Parameter {
493                name: "error_type".to_string(),
494                type_hint: "string".to_string(),
495                description: "Error type (e.g., 'invalid_request_error', 'server_error')"
496                    .to_string(),
497                required: false,
498            },
499            Parameter {
500                name: "status".to_string(),
501                type_hint: "number".to_string(),
502                description: "HTTP status code (default: 500)".to_string(),
503                required: false,
504            },
505        ],
506        example: json!({
507            "type": "openai_error_response",
508            "message": "Invalid API key",
509            "error_type": "invalid_request_error",
510            "status": 401
511        }),
512        log_template: Some(
513            LogTemplate::new()
514                .with_info("-> OpenAI error {status}: {message}")
515                .with_debug("OpenAI openai_error_response: status={status} type={error_type} message={message}"),
516        ),
517    }
518}
519
520/// Action definition: List active chat sessions
521pub fn list_active_chats_action() -> ActionDefinition {
522    ActionDefinition {
523        name: "list_active_chats".to_string(),
524        description: "List currently active chat sessions".to_string(),
525        parameters: vec![],
526        example: json!({
527            "type": "list_active_chats"
528        }),
529        log_template: Some(
530            LogTemplate::new()
531                .with_info("-> OpenAI list active chats")
532                .with_debug("OpenAI list_active_chats"),
533        ),
534    }
535}
536
537/// Get OpenAI event types
538pub fn get_openai_event_types() -> Vec<EventType> {
539    vec![OPENAI_REQUEST_EVENT.clone()]
540}