unigateway 1.3.0

Lightweight, local-first LLM gateway for developers. A stable, single-binary unified entry point for all your AI tools and models.
use std::future::Future;
use std::sync::Arc;

use axum::response::Response;
use tracing::info;
use unigateway_host::{
    flow::{missing_upstream_api_key_response, resolve_core_only_host_flow},
    host::{HostEnvProvider, HostPoolSource},
};
use unigateway_protocol::RuntimeHttpResponse;

use crate::types::GatewayRequestState;

use super::request_flow::PreparedGatewayRequest;
use super::response_flow::respond_prepared_host_result;

type BeforeExecuteHook = for<'a> fn(&GatewayRequestState, &PreparedGatewayRequest<'a>);

#[derive(Clone, Copy)]
pub(super) struct HostExecutionSpec {
    endpoint: &'static str,
    env_provider: HostEnvProvider,
    unavailable_message: &'static str,
    before_execute: Option<BeforeExecuteHook>,
}

pub(super) fn openai_chat_spec() -> HostExecutionSpec {
    HostExecutionSpec {
        endpoint: "/v1/chat/completions",
        env_provider: HostEnvProvider::OpenAi,
        unavailable_message: "no provider pool available for chat",
        before_execute: None,
    }
}

pub(super) fn openai_responses_spec() -> HostExecutionSpec {
    HostExecutionSpec {
        endpoint: "/v1/responses",
        env_provider: HostEnvProvider::OpenAi,
        unavailable_message: "no openai-compatible provider available for responses",
        before_execute: None,
    }
}

pub(super) fn anthropic_chat_spec() -> HostExecutionSpec {
    HostExecutionSpec {
        endpoint: "/v1/messages",
        env_provider: HostEnvProvider::Anthropic,
        unavailable_message: "no provider pool available for messages",
        before_execute: Some(log_anthropic_request_execution),
    }
}

pub(super) fn openai_embeddings_spec() -> HostExecutionSpec {
    HostExecutionSpec {
        endpoint: "/v1/embeddings",
        env_provider: HostEnvProvider::OpenAi,
        unavailable_message: "no openai-compatible provider available for embeddings",
        before_execute: None,
    }
}

pub(super) async fn execute_prepared_host_request<'prepared, Request, Execute, CoreFuture>(
    state: &Arc<GatewayRequestState>,
    prepared: &'prepared PreparedGatewayRequest<'_>,
    request: Request,
    spec: HostExecutionSpec,
    execute: Execute,
) -> Response
where
    Execute: FnOnce(
        &'prepared PreparedGatewayRequest<'_>,
        HostPoolSource<'prepared>,
        Request,
    ) -> CoreFuture,
    CoreFuture: Future<Output = anyhow::Result<Option<RuntimeHttpResponse>>>,
{
    if let Some(before_execute) = spec.before_execute {
        before_execute(state.as_ref(), prepared);
    }

    let source = match host_pool_source(state, prepared, spec.env_provider) {
        Ok(source) => source,
        Err(HostPoolSourceError::MissingUpstreamApiKey) => {
            return respond_prepared_host_result(
                state,
                prepared,
                spec.endpoint,
                Err(missing_upstream_api_key_response()),
            )
            .await;
        }
    };

    let result =
        resolve_core_only_host_flow(execute(prepared, source, request), spec.unavailable_message)
            .await;
    respond_prepared_host_result(state, prepared, spec.endpoint, result).await
}

fn host_pool_source<'prepared>(
    state: &GatewayRequestState,
    prepared: &'prepared PreparedGatewayRequest<'_>,
    env_provider: HostEnvProvider,
) -> Result<HostPoolSource<'prepared>, HostPoolSourceError> {
    if let Some(auth) = prepared.auth.as_ref() {
        return Ok(HostPoolSource::Service(&auth.key.service_id));
    }

    let api_key_override = (!prepared.token.is_empty()).then_some(prepared.token.as_str());
    if api_key_override.is_none() && env_api_key(state, env_provider).is_empty() {
        return Err(HostPoolSourceError::MissingUpstreamApiKey);
    }

    Ok(HostPoolSource::Env {
        provider: env_provider,
        api_key_override,
    })
}

fn env_api_key(state: &GatewayRequestState, provider: HostEnvProvider) -> &str {
    state.provider_api_key(provider)
}

fn log_anthropic_request_execution(
    state: &GatewayRequestState,
    prepared: &PreparedGatewayRequest<'_>,
) {
    let endpoint = "/v1/messages";
    let env_api_key = env_api_key(state, HostEnvProvider::Anthropic);

    info!(
        endpoint,
        gateway_key_matched = prepared.auth.is_some(),
        token_present = !prepared.token.is_empty(),
        "anthropic request authentication result"
    );

    if prepared.auth.is_none() {
        info!(
            endpoint,
            token_present = !prepared.token.is_empty(),
            env_key_present = !env_api_key.is_empty(),
            using_env_fallback = prepared.token.is_empty() && !env_api_key.is_empty(),
            "anthropic request falling back to env upstream key"
        );
    }
}

enum HostPoolSourceError {
    MissingUpstreamApiKey,
}