Skip to main content

netget/client/openai/
actions.rs

1//! OpenAI client protocol actions implementation
2
3use crate::llm::actions::{
4    client_trait::{Client, ClientActionResult},
5    protocol_trait::Protocol,
6    ActionDefinition, Parameter, ParameterDefinition,
7};
8use crate::protocol::log_template::LogTemplate;
9use crate::protocol::EventType;
10use crate::state::app_state::AppState;
11use anyhow::{Context, Result};
12use serde_json::json;
13use std::sync::LazyLock;
14
15/// OpenAI client connected event
16pub static OPENAI_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
17    EventType::new(
18        "openai_connected",
19        "OpenAI client initialized and ready to make API requests",
20        json!({
21            "type": "send_chat_completion",
22            "messages": [
23                {"role": "user", "content": "Hello!"}
24            ],
25            "model": "gpt-3.5-turbo"
26        }),
27    )
28    .with_parameters(vec![Parameter {
29        name: "api_endpoint".to_string(),
30        type_hint: "string".to_string(),
31        description: "OpenAI API endpoint URL".to_string(),
32        required: true,
33    }])
34});
35
36/// OpenAI client response received event
37pub static OPENAI_CLIENT_RESPONSE_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
38    EventType::new(
39        "openai_response_received",
40        "Response received from OpenAI API",
41        json!({
42            "type": "send_chat_completion",
43            "messages": [
44                {"role": "user", "content": "Follow-up question"}
45            ]
46        }),
47    )
48    .with_parameters(vec![
49        Parameter {
50            name: "response_type".to_string(),
51            type_hint: "string".to_string(),
52            description: "Type of response (chat_completion, embedding, etc.)".to_string(),
53            required: true,
54        },
55        Parameter {
56            name: "content".to_string(),
57            type_hint: "string".to_string(),
58            description: "Response content or error message".to_string(),
59            required: true,
60        },
61        Parameter {
62            name: "model".to_string(),
63            type_hint: "string".to_string(),
64            description: "Model used for the request".to_string(),
65            required: false,
66        },
67        Parameter {
68            name: "usage".to_string(),
69            type_hint: "object".to_string(),
70            description: "Token usage statistics".to_string(),
71            required: false,
72        },
73    ])
74});
75
76/// Largest `max_tokens` this client will forward.
77///
78/// Comfortably above any real model's context window, so it never truncates a legitimate
79/// request; its job is to keep the value inside `u32` and away from the narrowing casts that
80/// used to sit between the model and the wire.
81const MAX_COMPLETION_TOKENS: u64 = 1_000_000;
82
83/// OpenAI client protocol action handler
84pub struct OpenAiClientProtocol;
85
86impl Default for OpenAiClientProtocol {
87    fn default() -> Self {
88        Self::new()
89    }
90}
91
92impl OpenAiClientProtocol {
93    pub fn new() -> Self {
94        Self
95    }
96}
97
98// Implement Protocol trait (common functionality)
99impl Protocol for OpenAiClientProtocol {
100    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
101        vec![
102            ParameterDefinition {
103                name: "api_key".to_string(),
104                description: "OpenAI API key for authentication".to_string(),
105                type_hint: "string".to_string(),
106                required: true,
107                example: json!("sk-..."),
108                default: None,
109            },
110            ParameterDefinition {
111                name: "default_model".to_string(),
112                description: "Default model to use for requests".to_string(),
113                type_hint: "string".to_string(),
114                required: false,
115                example: json!("gpt-4"),
116                default: None,
117            },
118            ParameterDefinition {
119                name: "organization".to_string(),
120                description: "OpenAI organization ID".to_string(),
121                type_hint: "string".to_string(),
122                required: false,
123                example: json!("org-..."),
124                default: None,
125            },
126        ]
127    }
128    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
129        vec![
130            ActionDefinition {
131                name: "send_chat_completion".to_string(),
132                description: "Send a chat completion request to OpenAI".to_string(),
133                parameters: vec![
134                    Parameter {
135                        name: "messages".to_string(),
136                        type_hint: "array".to_string(),
137                        description: "Array of message objects with role and content".to_string(),
138                        required: true,
139                    },
140                    Parameter {
141                        name: "model".to_string(),
142                        type_hint: "string".to_string(),
143                        description: "Model to use (e.g., gpt-4, gpt-3.5-turbo)".to_string(),
144                        required: false,
145                    },
146                    Parameter {
147                        name: "temperature".to_string(),
148                        type_hint: "number".to_string(),
149                        description: "Sampling temperature (0.0 to 2.0)".to_string(),
150                        required: false,
151                    },
152                    Parameter {
153                        name: "max_tokens".to_string(),
154                        type_hint: "number".to_string(),
155                        description: "Maximum tokens to generate".to_string(),
156                        required: false,
157                    },
158                    Parameter {
159                        name: "functions".to_string(),
160                        type_hint: "array".to_string(),
161                        description: "Array of function definitions for function calling"
162                            .to_string(),
163                        required: false,
164                    },
165                ],
166                example: json!({
167                    "type": "send_chat_completion",
168                    "messages": [
169                        {"role": "user", "content": "Hello!"}
170                    ],
171                    "model": "gpt-3.5-turbo"
172                }),
173                log_template: None,
174            },
175            ActionDefinition {
176                name: "send_embedding_request".to_string(),
177                description: "Generate embeddings for text".to_string(),
178                parameters: vec![
179                    Parameter {
180                        name: "input".to_string(),
181                        type_hint: "string | array".to_string(),
182                        description: "Text or array of texts to embed".to_string(),
183                        required: true,
184                    },
185                    Parameter {
186                        name: "model".to_string(),
187                        type_hint: "string".to_string(),
188                        description: "Embedding model to use (e.g., text-embedding-ada-002)"
189                            .to_string(),
190                        required: false,
191                    },
192                ],
193                example: json!({
194                    "type": "send_embedding_request",
195                    "input": "The quick brown fox",
196                    "model": "text-embedding-ada-002"
197                }),
198                log_template: None,
199            },
200            ActionDefinition {
201                name: "disconnect".to_string(),
202                description: "Close the OpenAI client connection".to_string(),
203                parameters: vec![],
204                example: json!({
205                    "type": "disconnect"
206                }),
207                log_template: None,
208            },
209        ]
210    }
211    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
212        vec![ActionDefinition {
213            name: "send_chat_completion".to_string(),
214            description: "Send another chat completion in response to received data".to_string(),
215            parameters: vec![
216                Parameter {
217                    name: "messages".to_string(),
218                    type_hint: "array".to_string(),
219                    description: "Array of message objects".to_string(),
220                    required: true,
221                },
222                Parameter {
223                    name: "model".to_string(),
224                    type_hint: "string".to_string(),
225                    description: "Model to use".to_string(),
226                    required: false,
227                },
228            ],
229            example: json!({
230                "type": "send_chat_completion",
231                "messages": [
232                    {"role": "user", "content": "Follow-up question"}
233                ]
234            }),
235            log_template: Some(
236                LogTemplate::new()
237                    .with_info("-> OpenAI chat completion ({model})")
238                    .with_debug("OpenAI send_chat_completion: model={model}"),
239            ),
240        }]
241    }
242    fn protocol_name(&self) -> &'static str {
243        "OpenAI"
244    }
245    fn get_event_types(&self) -> Vec<EventType> {
246        vec![
247            EventType::new(
248                "openai_connected",
249                "Triggered when OpenAI client is initialized",
250                json!({"type": "placeholder", "event_id": "openai_connected"}),
251            ),
252            EventType::new(
253                "openai_response_received",
254                "Triggered when OpenAI client receives a response",
255                json!({"type": "placeholder", "event_id": "openai_response_received"}),
256            ),
257        ]
258    }
259    fn stack_name(&self) -> &'static str {
260        "ETH>IP>TCP>TLS>HTTPS>OpenAI"
261    }
262    fn keywords(&self) -> Vec<&'static str> {
263        vec!["openai", "openai client", "gpt", "chatgpt", "openai api"]
264    }
265    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
266        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
267
268        ProtocolMetadataV2::builder()
269            .state(DevelopmentState::Experimental)
270            .implementation(
271                "async-openai 0.26 over a reqwest client built once per endpoint on \
272                 spawn_blocking, so the platform root store is not loaded on the async \
273                 runtime and a literal-IP host skips the system resolver. The API base comes \
274                 from remote_addr and nothing else: an absent one refuses rather than \
275                 reaching async-openai's default of https://api.openai.com/v1.",
276            )
277            .llm_control("Full control over chat completions, embeddings, and function calling")
278            .e2e_testing(
279                "tests/client/openai/. endpoint_and_limits_test pins the API base against \
280                 the vendor default and the max_tokens/temperature ranges; \
281                 command_channel_test drives the injected-action path against a loopback \
282                 OpenAI-shaped stub; e2e_test starts the real binary against a dead loopback \
283                 port. No test contacts api.openai.com and no real key is involved, so this \
284                 is not validation against the real API.",
285            )
286            .build()
287    }
288    fn description(&self) -> &'static str {
289        "OpenAI API client for LLM interactions"
290    }
291    fn example_prompt(&self) -> &'static str {
292        "Connect to OpenAI and ask GPT-4 to explain quantum computing"
293    }
294    fn group_name(&self) -> &'static str {
295        "AI & API"
296    }
297    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
298        use crate::llm::actions::StartupExamples;
299        use serde_json::json;
300
301        StartupExamples::new(
302            // LLM mode: LLM controls OpenAI API interactions
303            json!({
304                "type": "open_client",
305                "remote_addr": "https://api.openai.com",
306                "base_stack": "openai",
307                "instruction": "Ask GPT-4 to explain quantum computing in simple terms",
308                "startup_params": {
309                    "api_key": "sk-...",
310                    "default_model": "gpt-4"
311                }
312            }),
313            // Script mode: Code-based OpenAI interactions
314            json!({
315                "type": "open_client",
316                "remote_addr": "https://api.openai.com",
317                "base_stack": "openai",
318                "startup_params": {
319                    "api_key": "sk-...",
320                    "default_model": "gpt-3.5-turbo"
321                },
322                "event_handlers": [{
323                    "event_pattern": "openai_response_received",
324                    "handler": {
325                        "type": "script",
326                        "language": "python",
327                        "code": "<openai_client_handler>"
328                    }
329                }]
330            }),
331            // Static mode: Fixed chat completion request
332            json!({
333                "type": "open_client",
334                "remote_addr": "https://api.openai.com",
335                "base_stack": "openai",
336                "startup_params": {
337                    "api_key": "sk-...",
338                    "default_model": "gpt-3.5-turbo"
339                },
340                "event_handlers": [
341                    {
342                        "event_pattern": "openai_connected",
343                        "handler": {
344                            "type": "static",
345                            "actions": [{
346                                "type": "send_chat_completion",
347                                "messages": [
348                                    {"role": "user", "content": "Hello!"}
349                                ],
350                                "model": "gpt-3.5-turbo"
351                            }]
352                        }
353                    },
354                    {
355                        "event_pattern": "openai_response_received",
356                        "handler": {
357                            "type": "static",
358                            "actions": [{
359                                "type": "disconnect"
360                            }]
361                        }
362                    }
363                ]
364            }),
365        )
366    }
367}
368
369// Implement Client trait (client-specific functionality)
370impl Client for OpenAiClientProtocol {
371    fn connect(
372        &self,
373        ctx: crate::protocol::ConnectContext,
374    ) -> std::pin::Pin<
375        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
376    > {
377        Box::pin(async move {
378            use crate::client::openai::OpenAiClient;
379            OpenAiClient::connect_with_llm_actions(
380                ctx.remote_addr,
381                ctx.llm_client,
382                ctx.state,
383                ctx.status_tx,
384                ctx.client_id,
385                ctx.startup_params,
386            )
387            .await
388        })
389    }
390    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
391        let action_type = action
392            .get("type")
393            .and_then(|v| v.as_str())
394            .context("Missing 'type' field in action")?;
395
396        match action_type {
397            "send_chat_completion" => {
398                let messages = action
399                    .get("messages")
400                    .context("Missing 'messages' field")?
401                    .clone();
402
403                let model = action
404                    .get("model")
405                    .and_then(|v| v.as_str())
406                    .map(|s| s.to_string());
407
408                // `as f32` turns a NaN or an out-of-range value into something the request
409                // serialiser cannot represent, and OpenAI's own range is 0.0-2.0. Refuse
410                // rather than send a number the API will reject with a message the model
411                // never sees.
412                let temperature = match action.get("temperature") {
413                    None | Some(serde_json::Value::Null) => None,
414                    Some(v) => {
415                        let t = v.as_f64().ok_or_else(|| {
416                            anyhow::anyhow!(
417                                "send_chat_completion 'temperature' must be a number, got {v}"
418                            )
419                        })?;
420                        if !t.is_finite() || !(0.0..=2.0).contains(&t) {
421                            return Err(anyhow::anyhow!(
422                                "send_chat_completion 'temperature' {t} is out of range; use \
423                                 0.0-2.0"
424                            ));
425                        }
426                        Some(t)
427                    }
428                };
429
430                // Two narrowing casts used to sit between the model and the wire: `as u32`
431                // here and `as u16` where the request was built. `max_tokens: 70000` became
432                // 4464 and `max_tokens: 4294967296` became 0 — a budget silently replaced by
433                // a different, smaller one, which reads on the wire as a deliberate choice.
434                // Refuse instead, and name the range so the repair loop can correct it.
435                let max_tokens = match action.get("max_tokens") {
436                    None | Some(serde_json::Value::Null) => None,
437                    Some(v) => {
438                        let n = v.as_u64().ok_or_else(|| {
439                            anyhow::anyhow!(
440                                "send_chat_completion 'max_tokens' must be a positive whole \
441                                 number, got {v}"
442                            )
443                        })?;
444                        if n == 0 || n > MAX_COMPLETION_TOKENS {
445                            return Err(anyhow::anyhow!(
446                                "send_chat_completion 'max_tokens' {n} is out of range; use \
447                                 1-{MAX_COMPLETION_TOKENS}"
448                            ));
449                        }
450                        Some(n as u32)
451                    }
452                };
453
454                let functions = action.get("functions").cloned();
455
456                Ok(ClientActionResult::Custom {
457                    name: "openai_chat_completion".to_string(),
458                    data: json!({
459                        "messages": messages,
460                        "model": model,
461                        "temperature": temperature,
462                        "max_tokens": max_tokens,
463                        "functions": functions,
464                    }),
465                })
466            }
467            "send_embedding_request" => {
468                let input = action
469                    .get("input")
470                    .context("Missing 'input' field")?
471                    .clone();
472
473                let model = action
474                    .get("model")
475                    .and_then(|v| v.as_str())
476                    .map(|s| s.to_string());
477
478                Ok(ClientActionResult::Custom {
479                    name: "openai_embedding".to_string(),
480                    data: json!({
481                        "input": input,
482                        "model": model,
483                    }),
484                })
485            }
486            "disconnect" => Ok(ClientActionResult::Disconnect),
487            _ => Err(anyhow::anyhow!(
488                "Unknown OpenAI client action: {}",
489                action_type
490            )),
491        }
492    }
493}