Skip to main content

formal_ai/
server.rs

1use std::io::{Read, Write};
2use std::net::{TcpListener, TcpStream};
3use std::sync::atomic::{AtomicBool, Ordering};
4
5use serde::Serialize;
6use serde_json::{json, Value};
7
8use crate::anthropic::{
9    anthropic_message_sse, create_anthropic_message_with_solver_and_memory, AnthropicContentBlock,
10    AnthropicMessagesRequest,
11};
12use crate::context_capacity::ContextCapacity;
13use crate::engine::{knowledge_graph, render_thinking_steps};
14use crate::gemini::{
15    create_gemini_generate_content_response_with_solver_and_memory, gemini_model_list,
16    gemini_model_metadata, gemini_response_sse, vertex_model_list, GeminiGenerateContentRequest,
17};
18use crate::mcp::handle_mcp_request;
19use crate::memory_sync::SyncStore;
20use crate::network_endpoint::{handle_links_query_request, handle_network_request};
21use crate::protocol::{
22    chat_exchange_to_record, chat_tool_executions, create_chat_completion_with_solver_and_memory,
23    create_response_with_solver_and_memory, messages_exchange_to_record,
24    responses_exchange_to_record, ChatCompletion, ChatCompletionRequest, ResponsesRequest,
25};
26use crate::responses_stream::responses_sse_response;
27use crate::seed::{canonical_model_id, merged_bundle, try_resolve_model_id};
28use crate::solver::{ExecutionSurface, SolverConfig, UniversalSolver};
29use crate::telegram::handle_telegram_webhook;
30
31static HTTP_AGENT_MODE_FORCED: AtomicBool = AtomicBool::new(false);
32
33pub const ADVERTISED_MAX_OUTPUT_TOKENS: i64 = 8_192;
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct ApiHttpResponse {
37    pub status_code: u16,
38    pub content_type: &'static str,
39    pub body: String,
40    /// Whether this response was served through a deprecated route alias.
41    ///
42    /// Set for the legacy `/v1/graph` links-network alias so the wire response
43    /// carries a `deprecation` marker in its metadata (an HTTP `deprecation`
44    /// header plus a successor `link` to the canonical `/v1/network` endpoint)
45    /// while the JSON payload stays byte-for-byte identical to the canonical one.
46    pub deprecated: bool,
47}
48
49impl ApiHttpResponse {
50    /// Flag this response as served through a deprecated route alias so the wire
51    /// layer emits the `deprecation` / successor-`link` metadata. The payload is
52    /// left untouched, keeping the alias byte-for-byte identical to the canonical
53    /// endpoint.
54    #[must_use]
55    const fn into_deprecated_alias(mut self) -> Self {
56        self.deprecated = true;
57        self
58    }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Default)]
62pub struct ApiAuthConfig {
63    pub bearer_token: Option<String>,
64}
65
66struct ParsedHttpRequest {
67    method: String,
68    path: String,
69    headers: Vec<(String, String)>,
70    body: String,
71}
72
73impl ApiAuthConfig {
74    #[must_use]
75    pub fn bearer_token(token: impl Into<String>) -> Self {
76        Self {
77            bearer_token: Some(token.into()),
78        }
79    }
80
81    #[must_use]
82    pub fn from_env() -> Self {
83        Self {
84            bearer_token: first_non_empty_env(&[
85                "FORMAL_AI_API_BEARER_TOKEN",
86                "FORMAL_AI_HTTP_BEARER_TOKEN",
87                "FORMAL_AI_API_TOKEN",
88            ]),
89        }
90    }
91
92    #[must_use]
93    pub fn allows(&self, headers: &[(&str, &str)]) -> bool {
94        let Some(expected) = self.bearer_token.as_deref() else {
95            return true;
96        };
97        bearer_token_from_headers(headers).is_some_and(|actual| actual == expected)
98            || api_key_from_headers(headers).is_some_and(|actual| actual == expected)
99    }
100}
101
102#[must_use]
103pub fn handle_api_request(method: &str, path: &str, body: &str) -> ApiHttpResponse {
104    handle_api_request_with_auth(method, path, &[], body, &ApiAuthConfig::from_env())
105}
106
107#[must_use]
108pub fn handle_api_request_with_headers(
109    method: &str,
110    path: &str,
111    headers: &[(&str, &str)],
112    body: &str,
113) -> ApiHttpResponse {
114    handle_api_request_with_auth(method, path, headers, body, &ApiAuthConfig::from_env())
115}
116
117/// Record a live chat exchange into the shared memory log (issue #540), never
118/// failing the request over it: a write error is logged and swallowed so the
119/// answer still reaches the client.
120fn record_exchange_best_effort(
121    store: &mut SyncStore,
122    exchange: Option<(String, String)>,
123    tools: &[crate::memory_sync::RecordedToolExecution],
124) {
125    let Some((prompt, answer)) = exchange else {
126        return;
127    };
128    if let Err(error) = store.record_chat_exchange_with_tools(&prompt, &answer, tools) {
129        eprintln!("[memory] failed to record live chat exchange: {error}");
130    }
131}
132
133#[must_use]
134pub fn handle_api_request_with_auth(
135    method: &str,
136    path: &str,
137    headers: &[(&str, &str)],
138    body: &str,
139    auth: &ApiAuthConfig,
140) -> ApiHttpResponse {
141    let normalized_path = path.split('?').next().unwrap_or(path);
142    let authorized = !requires_bearer_auth(method, normalized_path) || auth.allows(headers);
143    let response = dispatch_api_request_with_auth(method, path, headers, body, auth);
144    crate::dialog_log::record_api_exchange_if_enabled(
145        method, path, headers, body, &response, authorized,
146    );
147    response
148}
149
150fn dispatch_api_request_with_auth(
151    method: &str,
152    path: &str,
153    headers: &[(&str, &str)],
154    body: &str,
155    auth: &ApiAuthConfig,
156) -> ApiHttpResponse {
157    let _foreground_activity = crate::dreaming_runtime::ForegroundActivity::begin();
158    let normalized_path = path.split('?').next().unwrap_or(path);
159    let query = path.split_once('?').map_or("", |(_, q)| q);
160
161    if normalized_path == "/mcp" && !mcp_origin_allowed(headers) {
162        return error_response(403, "MCP origin is not allowed");
163    }
164    if requires_bearer_auth(method, normalized_path) && !auth.allows(headers) {
165        return error_response(401, "missing or invalid bearer token");
166    }
167
168    crate::dialog_log::trace_request_if_enabled(method, normalized_path, body);
169
170    if let Some(response) = handle_dynamic_protocol_route(method, normalized_path, body) {
171        return response;
172    }
173
174    match (method, normalized_path) {
175        ("OPTIONS", _) => ApiHttpResponse {
176            status_code: 204,
177            content_type: "application/json",
178            body: String::new(),
179            deprecated: false,
180        },
181        ("GET", "/health") => json_response(
182            200,
183            &json!({
184                "status": "ok",
185                "model": canonical_model_id(),
186            }),
187        ),
188        ("GET", "/v1/models" | "/api/openai/v1/models") => handle_openai_models_request(),
189        ("GET", "/v1/network" | "/api/formal-ai/v1/network") => handle_network_request(query),
190        // Deprecated alias: the project's associative vocabulary is a *links
191        // network*, not a graph (issue #664). `/v1/graph` keeps working for
192        // existing desktop / VS Code / e2e clients but returns the same payload
193        // flagged deprecated so the wire response points at `/v1/network`.
194        ("GET", "/v1/graph" | "/api/formal-ai/v1/graph") => {
195            handle_network_request(query).into_deprecated_alias()
196        }
197        ("GET", "/v1/bundle" | "/api/formal-ai/v1/bundle") => {
198            links_notation_response(200, merged_bundle())
199        }
200        ("GET", "/v1/links" | "/api/formal-ai/v1/links") => {
201            links_notation_response(200, knowledge_graph().to_links_notation())
202        }
203        ("POST", "/v1/links/query" | "/api/formal-ai/v1/links/query") => {
204            handle_links_query_request(body)
205        }
206        ("GET", "/v1/memory" | "/api/formal-ai/v1/memory") => {
207            links_notation_response(200, SyncStore::open().to_links_notation())
208        }
209        ("GET", "/v1/memory/since" | "/api/formal-ai/v1/memory/since") => {
210            handle_memory_since_request(query)
211        }
212        ("POST", "/v1/memory/import" | "/api/formal-ai/v1/memory/import") => {
213            handle_memory_import_request(body)
214        }
215        ("POST", "/v1/messages" | "/api/anthropic/v1/messages") => {
216            handle_anthropic_messages_request(body)
217        }
218        ("POST", "/v1/chat/completions" | "/api/openai/v1/chat/completions") => {
219            match serde_json::from_str::<ChatCompletionRequest>(body) {
220                Ok(request) => {
221                    if let Some(response) = unsupported_model_response(request.model.as_deref()) {
222                        return response;
223                    }
224                    let solver = http_solver();
225                    let mut store = SyncStore::open();
226                    let completion = create_chat_completion_with_solver_and_memory(
227                        &request,
228                        &solver,
229                        store.events(),
230                    );
231                    record_exchange_best_effort(
232                        &mut store,
233                        chat_exchange_to_record(&request, &completion),
234                        &chat_tool_executions(&request.messages),
235                    );
236                    if request.stream {
237                        let include_usage = request
238                            .stream_options
239                            .is_some_and(|options| options.include_usage);
240                        chat_completion_sse_response(&completion, include_usage)
241                    } else {
242                        json_response(200, &completion)
243                    }
244                }
245                Err(error) => error_response(400, &format!("invalid chat request: {error}")),
246            }
247        }
248        ("POST", "/v1/responses" | "/api/openai/v1/responses") => {
249            match serde_json::from_str::<ResponsesRequest>(body) {
250                Ok(request) => {
251                    if let Some(response) = unsupported_model_response(request.model.as_deref()) {
252                        return response;
253                    }
254                    let solver = http_solver();
255                    let mut store = SyncStore::open();
256                    let response =
257                        create_response_with_solver_and_memory(&request, &solver, store.events());
258                    record_exchange_best_effort(
259                        &mut store,
260                        responses_exchange_to_record(&request, &response),
261                        &chat_tool_executions(&request.to_chat_completion_request().messages),
262                    );
263                    if request.stream {
264                        responses_sse_response(&response)
265                    } else {
266                        json_response(200, &response)
267                    }
268                }
269                Err(error) => error_response(400, &format!("invalid responses request: {error}")),
270            }
271        }
272        ("POST", "/mcp") => handle_mcp_request(body, &http_solver()),
273        ("GET", "/mcp") => error_response(405, "MCP SSE streams are not supported"),
274        ("POST", "/telegram/webhook") => match handle_telegram_webhook(body) {
275            Ok(Some(reply)) => json_response(200, &reply),
276            Ok(None) => ApiHttpResponse {
277                status_code: 200,
278                content_type: "application/json",
279                body: String::new(),
280                deprecated: false,
281            },
282            Err(error) => error_response(400, &error.to_string()),
283        },
284        _ => error_response(404, "route not found"),
285    }
286}
287
288fn requires_bearer_auth(method: &str, normalized_path: &str) -> bool {
289    method != "OPTIONS"
290        && (normalized_path == "/mcp"
291            || normalized_path.starts_with("/v1/")
292            || normalized_path.starts_with("/api/"))
293}
294
295fn mcp_origin_allowed(headers: &[(&str, &str)]) -> bool {
296    let Some(origin) = headers
297        .iter()
298        .find_map(|(name, value)| name.eq_ignore_ascii_case("origin").then_some(*value))
299    else {
300        return true;
301    };
302    let Some(host) = headers
303        .iter()
304        .find_map(|(name, value)| name.eq_ignore_ascii_case("host").then_some(*value))
305    else {
306        return false;
307    };
308    let origin = origin.trim_end_matches('/');
309    origin
310        .strip_prefix("http://")
311        .or_else(|| origin.strip_prefix("https://"))
312        .is_some_and(|authority| authority.eq_ignore_ascii_case(host))
313}
314
315fn handle_dynamic_protocol_route(
316    method: &str,
317    normalized_path: &str,
318    body: &str,
319) -> Option<ApiHttpResponse> {
320    if method == "GET" && normalized_path == "/api/gemini/v1beta/models" {
321        return Some(json_response(200, &gemini_model_list()));
322    }
323    if method == "GET" {
324        if let Some(model) = gemini_model_metadata_path(normalized_path) {
325            return Some(json_response(
326                200,
327                &gemini_model_metadata(&format!("models/{model}")),
328            ));
329        }
330        if let Some((project, location)) = vertex_models_path(normalized_path) {
331            return Some(json_response(200, &vertex_model_list(&project, &location)));
332        }
333    }
334    if method == "POST" {
335        if let Some(model) = gemini_model_action_path(normalized_path, "generateContent") {
336            return Some(handle_gemini_generate_content_request(&model, false, body));
337        }
338        if let Some(model) = gemini_model_action_path(normalized_path, "streamGenerateContent") {
339            return Some(handle_gemini_generate_content_request(&model, true, body));
340        }
341        if let Some(route) = vertex_model_action_path(normalized_path, "generateContent") {
342            return Some(handle_gemini_generate_content_request(
343                &route.model,
344                false,
345                body,
346            ));
347        }
348        if let Some(route) = vertex_model_action_path(normalized_path, "streamGenerateContent") {
349            return Some(handle_gemini_generate_content_request(
350                &route.model,
351                true,
352                body,
353            ));
354        }
355    }
356    None
357}
358
359fn handle_openai_models_request() -> ApiHttpResponse {
360    let model_id = canonical_model_id();
361    let context = match ContextCapacity::current() {
362        Ok(context) => context,
363        Err(error) => return error_response(500, &error.to_string()),
364    };
365    let context_metadata = json!(context);
366    json_response(
367        200,
368        &json!({
369            "object": "list",
370            "data": [{
371                "id": model_id,
372                "slug": model_id,
373                "object": "model",
374                "owned_by": "link-assistant",
375                "context_window": context.context_window_tokens,
376                "context_window_tokens": context.context_window_tokens,
377                "context_used_tokens": context.context_used_tokens,
378                "context_used_fraction": context.context_used_fraction,
379                "disk_free_bytes": context.disk_free_bytes,
380                "memory_used_bytes": context.memory_used_bytes,
381                "avg_utf8_bytes_per_char": context.avg_utf8_bytes_per_char,
382                "context": context_metadata
383            }],
384            "models": [{
385                "id": model_id,
386                "slug": model_id,
387                "name": model_id,
388                "context_window": context.context_window_tokens,
389                "max_output_tokens": ADVERTISED_MAX_OUTPUT_TOKENS,
390                "context_window_tokens": context.context_window_tokens,
391                "context_used_tokens": context.context_used_tokens,
392                "context_used_fraction": context.context_used_fraction,
393                "disk_free_bytes": context.disk_free_bytes,
394                "memory_used_bytes": context.memory_used_bytes,
395                "avg_utf8_bytes_per_char": context.avg_utf8_bytes_per_char,
396                "context": context_metadata
397            }],
398            "rate_limit": {
399                "requests_per_minute": 60,
400                "tokens_per_minute": 60_000
401            }
402        }),
403    )
404}
405
406fn handle_gemini_generate_content_request(
407    model: &str,
408    stream: bool,
409    body: &str,
410) -> ApiHttpResponse {
411    let model = normalize_protocol_model_id(model);
412    if let Some(response) = unsupported_model_response(Some(&model)) {
413        return response;
414    }
415    match serde_json::from_str::<GeminiGenerateContentRequest>(body) {
416        Ok(request) => {
417            let solver = http_solver();
418            let mut store = SyncStore::open();
419            let chat_request = request.to_chat_completion_request(&model);
420            let response = create_gemini_generate_content_response_with_solver_and_memory(
421                &request,
422                &model,
423                &solver,
424                store.events(),
425            );
426            let answer = response["candidates"][0]["content"]["parts"]
427                .as_array()
428                .into_iter()
429                .flatten()
430                .filter_map(|part| part.get("text").and_then(Value::as_str))
431                .collect::<Vec<_>>()
432                .join("\n");
433            record_exchange_best_effort(
434                &mut store,
435                messages_exchange_to_record(&chat_request.messages, &answer),
436                &chat_tool_executions(&chat_request.messages),
437            );
438            if stream {
439                ApiHttpResponse {
440                    status_code: 200,
441                    content_type: "text/event-stream",
442                    body: gemini_response_sse(&response),
443                    deprecated: false,
444                }
445            } else {
446                json_response(200, &response)
447            }
448        }
449        Err(error) => error_response(400, &format!("invalid generateContent request: {error}")),
450    }
451}
452
453fn normalize_protocol_model_id(model: &str) -> String {
454    model
455        .strip_prefix("models/")
456        .unwrap_or(model)
457        .trim()
458        .to_owned()
459}
460
461fn gemini_model_metadata_path(path: &str) -> Option<String> {
462    let model = path.strip_prefix("/api/gemini/v1beta/models/")?;
463    (!model.contains(':')).then(|| normalize_protocol_model_id(model))
464}
465
466fn gemini_model_action_path(path: &str, action: &str) -> Option<String> {
467    let model = path.strip_prefix("/api/gemini/v1beta/models/")?;
468    let suffix = format!(":{action}");
469    model.strip_suffix(&suffix).map(normalize_protocol_model_id)
470}
471
472fn vertex_models_path(path: &str) -> Option<(String, String)> {
473    let route = path.strip_prefix("/api/vertex/v1/projects/")?;
474    let (project, route) = route.split_once("/locations/")?;
475    let (location, tail) = route.split_once("/publishers/google/models")?;
476    tail.is_empty()
477        .then(|| (project.to_owned(), location.to_owned()))
478}
479
480#[derive(Debug, Clone, PartialEq, Eq)]
481struct VertexModelRoute {
482    model: String,
483}
484
485fn vertex_model_action_path(path: &str, action: &str) -> Option<VertexModelRoute> {
486    let route = path.strip_prefix("/api/vertex/v1/projects/")?;
487    let (_project, route) = route.split_once("/locations/")?;
488    let (_location, model) = route.split_once("/publishers/google/models/")?;
489    let suffix = format!(":{action}");
490    let model = model.strip_suffix(&suffix)?;
491    Some(VertexModelRoute {
492        model: normalize_protocol_model_id(model),
493    })
494}
495
496fn first_non_empty_env(names: &[&str]) -> Option<String> {
497    names.iter().find_map(|name| {
498        let value = std::env::var(name).ok()?;
499        let trimmed = value.trim();
500        if trimmed.is_empty() {
501            None
502        } else {
503            Some(trimmed.to_owned())
504        }
505    })
506}
507
508fn bearer_token_from_headers<'a>(headers: &'a [(&str, &str)]) -> Option<&'a str> {
509    headers.iter().find_map(|(name, value)| {
510        if name.eq_ignore_ascii_case("authorization") {
511            parse_bearer_token(value)
512        } else {
513            None
514        }
515    })
516}
517
518fn api_key_from_headers<'a>(headers: &'a [(&str, &str)]) -> Option<&'a str> {
519    headers.iter().find_map(|(name, value)| {
520        (name.eq_ignore_ascii_case("x-api-key")
521            || name.eq_ignore_ascii_case("x-goog-api-key")
522            || name.eq_ignore_ascii_case("anthropic-api-key"))
523        .then(|| value.trim())
524        .filter(|value| !value.is_empty())
525    })
526}
527
528fn parse_bearer_token(value: &str) -> Option<&str> {
529    let mut parts = value.split_whitespace();
530    let scheme = parts.next()?;
531    let token = parts.next()?;
532    if parts.next().is_some() || !scheme.eq_ignore_ascii_case("bearer") {
533        return None;
534    }
535    Some(token)
536}
537
538fn unsupported_model_response(model: Option<&str>) -> Option<ApiHttpResponse> {
539    let model = model.map(str::trim).filter(|model| !model.is_empty())?;
540    if try_resolve_model_id(Some(model)).is_some() {
541        None
542    } else {
543        Some(error_response(
544            400,
545            &format!(
546                "unsupported model `{model}`; use `{}` or a configured alias",
547                canonical_model_id()
548            ),
549        ))
550    }
551}
552
553/// Enable agent-mode tool calls for HTTP solver instances created by this
554/// process, independent of `FORMAL_AI_AGENT_MODE`.
555///
556/// This is used by `formal-ai serve --agent-mode` so operators have an explicit
557/// command-line opt-in instead of relying only on an environment variable.
558pub fn enable_http_agent_mode_for_current_process() {
559    HTTP_AGENT_MODE_FORCED.store(true, Ordering::Relaxed);
560}
561
562fn http_solver() -> UniversalSolver {
563    let mut config = SolverConfig::from_env();
564    if HTTP_AGENT_MODE_FORCED.load(Ordering::Relaxed) {
565        config.agent_mode = true;
566    }
567    config.execution_surface = ExecutionSurface::HttpServer;
568    UniversalSolver::new(config)
569}
570
571/// Serialise a completed [`ChatCompletion`] as an OpenAI-compatible
572/// `chat.completion.chunk` SSE stream.
573///
574/// The Vercel AI SDK's `@ai-sdk/openai-compatible` provider (and every other
575/// OpenAI-compatible streaming parser) expects incremental `chat.completion.chunk`
576/// events carrying `choices[].delta` — not a single `chat.completion` payload.
577/// Shipping the non-streaming shape "worked" for text (the SDK falls back to
578/// scraping content out of the raw SSE stream, which is where the CLI's
579/// *"AI SDK dropped token data"* warning comes from) but silently dropped
580/// `tool_calls`, so the agent CLI never actually invoked the tool the planner
581/// requested. Emit chunks: an initial `role` delta, one delta per tool call
582/// (with full `function.arguments` in a single frame — the SDK stitches them
583/// back together), a final `finish_reason` chunk, an optional usage chunk when
584/// the client asks for it via `stream_options.include_usage`, and the closing
585/// `[DONE]` sentinel.
586fn chat_completion_sse_response(
587    completion: &ChatCompletion,
588    include_usage: bool,
589) -> ApiHttpResponse {
590    let mut body = String::new();
591    let base = json!({
592        "id": completion.id,
593        "object": "chat.completion.chunk",
594        "created": completion.created,
595        "model": completion.model,
596    });
597
598    let choice = completion.choices.first();
599
600    // Chunk 1: role delta.
601    let role_delta = json!({
602        "index": 0,
603        "delta": { "role": "assistant" },
604        "finish_reason": null,
605    });
606    body.push_str(&sse_chunk(&base, &role_delta));
607
608    // Chunk 2..N: reasoning, content, or tool_call deltas.
609    if let Some(choice) = choice {
610        let reasoning = if choice.message.reasoning_content.is_empty() {
611            render_thinking_steps(&choice.message.thinking_steps)
612        } else {
613            choice.message.reasoning_content.clone()
614        };
615        if !reasoning.is_empty() {
616            let delta = json!({
617                "index": 0,
618                "delta": {
619                    "reasoning_content": reasoning,
620                    "reasoning": reasoning,
621                },
622                "finish_reason": null,
623            });
624            body.push_str(&sse_chunk(&base, &delta));
625        }
626        let text = choice.message.content.plain_text();
627        if !text.is_empty() {
628            let delta = json!({
629                "index": 0,
630                "delta": { "content": text },
631                "finish_reason": null,
632            });
633            body.push_str(&sse_chunk(&base, &delta));
634        }
635        for (index, call) in choice.message.tool_calls.iter().enumerate() {
636            let delta = json!({
637                "index": 0,
638                "delta": {
639                    "tool_calls": [{
640                        "index": index,
641                        "id": call.id,
642                        "type": call.kind,
643                        "function": {
644                            "name": call.function.name,
645                            "arguments": call.function.arguments,
646                        }
647                    }]
648                },
649                "finish_reason": null,
650            });
651            body.push_str(&sse_chunk(&base, &delta));
652        }
653    }
654
655    // Final chunk: finish_reason.
656    let finish_reason = choice.map_or_else(
657        || String::from("stop"),
658        |choice| choice.finish_reason.clone(),
659    );
660    let final_chunk = json!({
661        "index": 0,
662        "delta": {},
663        "finish_reason": finish_reason,
664    });
665    body.push_str(&sse_chunk(&base, &final_chunk));
666
667    // Optional usage chunk — the AI SDK reads token counts from here when
668    // `stream_options.include_usage` is set (per OpenAI's spec).
669    if include_usage {
670        let usage_payload = json!({
671            "id": completion.id,
672            "object": "chat.completion.chunk",
673            "created": completion.created,
674            "model": completion.model,
675            "choices": [],
676            "usage": {
677                "prompt_tokens": completion.usage.prompt_tokens,
678                "completion_tokens": completion.usage.completion_tokens,
679                "total_tokens": completion.usage.total_tokens,
680            }
681        });
682        body.push_str("data: ");
683        body.push_str(&usage_payload.to_string());
684        body.push_str("\n\n");
685    }
686
687    body.push_str("data: [DONE]\n\n");
688
689    ApiHttpResponse {
690        status_code: 200,
691        content_type: "text/event-stream",
692        body,
693        deprecated: false,
694    }
695}
696
697/// Serialise a single OpenAI streaming chunk: merge `base` (id/object/created/model)
698/// with a `choices` entry and emit it as an SSE `data:` frame.
699fn sse_chunk(base: &Value, choice: &Value) -> String {
700    let mut merged = base.clone();
701    if let Value::Object(map) = &mut merged {
702        map.insert(String::from("choices"), Value::Array(vec![choice.clone()]));
703    }
704    format!("data: {merged}\n\n")
705}
706
707/// Translate an Anthropic Messages request (`POST /v1/messages`) so the `claude`
708/// CLI can target the local server directly (R4 / ROADMAP D4). The underlying
709/// reasoning is the same OpenAI-compatible solver (R7); only the envelope is
710/// translated, plus an Anthropic SSE stream when `stream: true`.
711fn handle_anthropic_messages_request(body: &str) -> ApiHttpResponse {
712    match serde_json::from_str::<AnthropicMessagesRequest>(body) {
713        Ok(request) => {
714            if let Some(response) = unsupported_model_response(request.model.as_deref()) {
715                return response;
716            }
717            let solver = http_solver();
718            let mut store = SyncStore::open();
719            let chat_request = request.to_chat_completion_request();
720            let message =
721                create_anthropic_message_with_solver_and_memory(&request, &solver, store.events());
722            let answer = message
723                .content
724                .iter()
725                .filter_map(|block| match block {
726                    AnthropicContentBlock::Text { text } => Some(text.as_str()),
727                    _ => None,
728                })
729                .collect::<Vec<_>>()
730                .join("\n");
731            record_exchange_best_effort(
732                &mut store,
733                messages_exchange_to_record(&chat_request.messages, &answer),
734                &chat_tool_executions(&chat_request.messages),
735            );
736            if request.stream {
737                ApiHttpResponse {
738                    status_code: 200,
739                    content_type: "text/event-stream",
740                    body: anthropic_message_sse(&message),
741                    deprecated: false,
742                }
743            } else {
744                json_response(200, &message)
745            }
746        }
747        Err(error) => error_response(400, &format!("invalid messages request: {error}")),
748    }
749}
750
751/// Evaluate a LinksQL query (`POST /v1/links/query`, ROADMAP D3 / R6). The body
752/// is a Links-Notation envelope carrying the query string; the response is the
753/// matched nodes/edges as a Links-Notation envelope (R7 keeps this internal
754/// channel Links-native rather than introducing a non-OpenAI JSON REST surface).
755/// Return the memory delta after a given event id (`GET /v1/memory/since?event=<id>`,
756/// ROADMAP D1 / R5c). The payload is `demo_memory` Links Notation (R7).
757fn handle_memory_since_request(query: &str) -> ApiHttpResponse {
758    let last_seen = query_param(query, "event");
759    let store = SyncStore::open();
760    links_notation_response(200, store.delta_links_notation(last_seen.as_deref()))
761}
762
763/// Merge an inbound `demo_memory` document into the shared store
764/// (`POST /v1/memory/import`, ROADMAP D1 / R5c).
765fn handle_memory_import_request(body: &str) -> ApiHttpResponse {
766    let mut store = SyncStore::open();
767    match store.import_links_notation(body) {
768        Ok(added) => json_response(
769            200,
770            &json!({
771                "object": "memory.import",
772                "added": added,
773                "total": store.events().len(),
774            }),
775        ),
776        Err(error) => error_response(500, &format!("failed to persist memory: {error}")),
777    }
778}
779
780fn query_param(query: &str, key: &str) -> Option<String> {
781    query
782        .split('&')
783        .filter(|part| !part.is_empty())
784        .find_map(|pair| {
785            let (name, value) = pair.split_once('=')?;
786            (name == key).then(|| value.to_owned())
787        })
788}
789
790pub(crate) const fn links_notation_response(status_code: u16, body: String) -> ApiHttpResponse {
791    ApiHttpResponse {
792        status_code,
793        content_type: "text/plain",
794        body,
795        deprecated: false,
796    }
797}
798
799pub fn serve(address: &str) -> std::io::Result<()> {
800    crate::dreaming_runtime::start_core_dreaming();
801    eprintln!(
802        "formal-ai shared memory: {}",
803        crate::shared_memory::shared_memory_path().display()
804    );
805    let listener = TcpListener::bind(address)?;
806    eprintln!("formal-ai server listening on http://{address}");
807
808    for stream in listener.incoming() {
809        match stream {
810            Ok(mut stream) => {
811                if let Err(error) = handle_connection(&mut stream) {
812                    eprintln!("request failed: {error}");
813                }
814            }
815            Err(error) => eprintln!("connection failed: {error}"),
816        }
817    }
818
819    Ok(())
820}
821
822fn handle_connection(stream: &mut TcpStream) -> std::io::Result<()> {
823    let Some(request) = read_request(stream)? else {
824        return Ok(());
825    };
826    let headers = request
827        .headers
828        .iter()
829        .map(|(name, value)| (name.as_str(), value.as_str()))
830        .collect::<Vec<_>>();
831    let response =
832        handle_api_request_with_headers(&request.method, &request.path, &headers, &request.body);
833    write_response(stream, &response)
834}
835
836fn read_request(stream: &mut TcpStream) -> std::io::Result<Option<ParsedHttpRequest>> {
837    let mut buffer = [0_u8; 8192];
838    let bytes_read = stream.read(&mut buffer)?;
839    if bytes_read == 0 {
840        return Ok(None);
841    }
842
843    let mut request_bytes = buffer[..bytes_read].to_vec();
844    let header_end = loop {
845        if let Some(position) = find_header_end(&request_bytes) {
846            break position;
847        }
848        let bytes_read = stream.read(&mut buffer)?;
849        if bytes_read == 0 {
850            return Ok(None);
851        }
852        request_bytes.extend_from_slice(&buffer[..bytes_read]);
853    };
854
855    let header_text = String::from_utf8_lossy(&request_bytes[..header_end]).to_string();
856    let content_length = content_length(&header_text);
857    let body_start = header_end + 4;
858
859    while request_bytes.len() < body_start.saturating_add(content_length) {
860        let bytes_read = stream.read(&mut buffer)?;
861        if bytes_read == 0 {
862            break;
863        }
864        request_bytes.extend_from_slice(&buffer[..bytes_read]);
865    }
866
867    let request_line = header_text.lines().next().unwrap_or_default();
868    let mut request_parts = request_line.split_whitespace();
869    let method = request_parts.next().unwrap_or_default().to_owned();
870    let path = request_parts.next().unwrap_or_default().to_owned();
871    let headers = request_headers(&header_text);
872    let body_end = body_start
873        .saturating_add(content_length)
874        .min(request_bytes.len());
875    let body = String::from_utf8_lossy(&request_bytes[body_start..body_end]).to_string();
876
877    Ok(Some(ParsedHttpRequest {
878        method,
879        path,
880        headers,
881        body,
882    }))
883}
884
885fn write_response(stream: &mut TcpStream, response: &ApiHttpResponse) -> std::io::Result<()> {
886    let status_text = match response.status_code {
887        200 => "200 OK",
888        204 => "204 No Content",
889        400 => "400 Bad Request",
890        401 => "401 Unauthorized",
891        403 => "403 Forbidden",
892        404 => "404 Not Found",
893        405 => "405 Method Not Allowed",
894        _ => "500 Internal Server Error",
895    };
896
897    // A response served through a deprecated route alias carries a wire-layer
898    // deprecation note so clients can migrate without inspecting the (byte-identical)
899    // body. The canonical `/v1/network` endpoint never emits it.
900    let deprecation_header = if response.deprecated {
901        "deprecation: true\r\nlink: </v1/network>; rel=\"successor-version\"\r\n"
902    } else {
903        ""
904    };
905
906    write!(
907        stream,
908        "HTTP/1.1 {status_text}\r\n\
909         content-type: {}\r\n\
910         content-length: {}\r\n\
911         access-control-allow-origin: *\r\n\
912         access-control-allow-methods: GET,POST,OPTIONS\r\n\
913         access-control-allow-headers: content-type,authorization,x-api-key,x-goog-api-key,anthropic-api-key\r\n\
914         {deprecation_header}\
915         connection: close\r\n\
916         \r\n{}",
917        response.content_type,
918        response.body.len(),
919        response.body
920    )
921}
922
923pub(crate) fn json_response<T: Serialize>(status_code: u16, value: &T) -> ApiHttpResponse {
924    match serde_json::to_string_pretty(value) {
925        Ok(body) => ApiHttpResponse {
926            status_code,
927            content_type: "application/json",
928            body,
929            deprecated: false,
930        },
931        Err(error) => error_response(500, &format!("failed to serialize response: {error}")),
932    }
933}
934
935pub(crate) fn error_response(status_code: u16, message: &str) -> ApiHttpResponse {
936    ApiHttpResponse {
937        status_code,
938        content_type: "application/json",
939        body: json!({
940            "error": {
941                "message": message,
942                "type": "formal_ai_error"
943            }
944        })
945        .to_string(),
946        deprecated: false,
947    }
948}
949
950fn find_header_end(bytes: &[u8]) -> Option<usize> {
951    bytes.windows(4).position(|window| window == b"\r\n\r\n")
952}
953
954fn request_headers(headers: &str) -> Vec<(String, String)> {
955    headers
956        .lines()
957        .skip(1)
958        .filter_map(|line| {
959            let (name, value) = line.split_once(':')?;
960            Some((name.trim().to_owned(), value.trim().to_owned()))
961        })
962        .collect()
963}
964
965fn content_length(headers: &str) -> usize {
966    headers
967        .lines()
968        .find_map(|line| {
969            let (name, value) = line.split_once(':')?;
970            if name.eq_ignore_ascii_case("content-length") {
971                value.trim().parse::<usize>().ok()
972            } else {
973                None
974            }
975        })
976        .unwrap_or(0)
977}