1use 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
15pub 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
36pub 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
76const MAX_COMPLETION_TOKENS: u64 = 1_000_000;
82
83pub 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
98impl 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 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 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 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
369impl 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 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 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}