1use 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
16pub 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 .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
69pub 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
84impl 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 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 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 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
216impl 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 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 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 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 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 Ok(ActionResult::Custom {
419 name: "list_active_chats".to_string(),
420 data: json!({"chats": []}),
421 })
422 }
423}
424
425pub 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
457pub 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
480pub 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
520pub 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
537pub fn get_openai_event_types() -> Vec<EventType> {
539 vec![OPENAI_REQUEST_EVENT.clone()]
540}