Files
zerogravity/src/api/mod.rs
Nikketryhard 34799fa2a9 feat: add official Gemini v1beta API routes
Replace /v1/gemini with proper Gemini API paths:
- POST /v1beta/models/{model}:generateContent (sync)
- POST /v1beta/models/{model}:streamGenerateContent (streaming)

Model is extracted from URL path. Uses axum wildcard
catch-all since colons in path segments are not supported.
2026-02-16 21:46:52 -06:00

180 lines
5.9 KiB
Rust

//! Axum API server — OpenAI-compatible Responses + Chat Completions endpoints.
mod completions;
mod gemini;
mod models;
mod polling;
mod responses;
mod types;
mod util;
use crate::constants::safe_truncate;
use crate::session::SessionManager;
use axum::{
extract::{DefaultBodyLimit, State},
http::StatusCode,
response::{IntoResponse, Json},
routing::{delete, get, post},
Router,
};
use std::sync::Arc;
use tower_http::cors::CorsLayer;
use tracing::warn;
use self::models::MODELS;
use self::types::TokenRequest;
// ─── Shared state ────────────────────────────────────────────────────────────
pub struct AppState {
pub backend: Arc<crate::backend::Backend>,
pub sessions: SessionManager,
pub mitm_store: crate::mitm::store::MitmStore,
pub quota_store: crate::quota::QuotaStore,
}
// ─── Router ──────────────────────────────────────────────────────────────────
pub fn router(state: Arc<AppState>) -> Router {
Router::new()
.route("/v1/responses", post(responses::handle_responses))
.route(
"/v1/chat/completions",
post(completions::handle_completions),
)
.route(
"/v1beta/{*path}",
post(gemini::handle_gemini_v1beta),
)
.route("/v1/models", get(handle_models))
.route("/v1/sessions", get(handle_list_sessions))
.route("/v1/sessions/{id}", delete(handle_delete_session))
.route("/v1/token", post(handle_set_token))
.route("/v1/usage", get(handle_usage))
.route("/v1/quota", get(handle_quota))
.route("/health", get(handle_health))
.route("/", get(handle_root))
.layer(CorsLayer::permissive())
.layer(DefaultBodyLimit::max(1_048_576)) // 1 MB
.with_state(state)
}
// ─── Simple handlers ─────────────────────────────────────────────────────────
async fn handle_root() -> Json<serde_json::Value> {
Json(serde_json::json!({
"service": "antigravity-openai-proxy",
"version": "3.3.0",
"runtime": "rust",
"endpoints": [
"/v1/chat/completions",
"/v1/responses",
"/v1beta/models/{model}:generateContent",
"/v1beta/models/{model}:streamGenerateContent",
"/v1/models",
"/v1/sessions",
"/v1/token",
"/v1/usage",
"/v1/quota",
"/health",
],
}))
}
async fn handle_health() -> Json<serde_json::Value> {
Json(serde_json::json!({"status": "ok"}))
}
async fn handle_models() -> Json<serde_json::Value> {
let models: Vec<serde_json::Value> = MODELS
.iter()
.map(|m| {
serde_json::json!({
"id": m.name,
"object": "model",
"created": 1700000000u64,
"owned_by": "antigravity",
"permission": [],
"root": m.name,
"parent": null,
"meta": {
"label": m.label,
"enum_value": m.model_enum,
},
})
})
.collect();
Json(serde_json::json!({"object": "list", "data": models}))
}
async fn handle_list_sessions(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
let sessions = state.sessions.list_sessions().await;
Json(serde_json::json!({"sessions": sessions}))
}
async fn handle_delete_session(
State(state): State<Arc<AppState>>,
axum::extract::Path(id): axum::extract::Path<String>,
) -> impl IntoResponse {
if state.sessions.delete_session(&id).await {
(
StatusCode::OK,
Json(serde_json::json!({"status": "deleted", "session_id": id})),
)
} else {
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": format!("Session not found: {id}")})),
)
}
}
async fn handle_set_token(
State(state): State<Arc<AppState>>,
Json(body): Json<TokenRequest>,
) -> impl IntoResponse {
if !body.token.starts_with("ya29.") {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Invalid token. Must start with ya29."})),
);
}
state.backend.set_oauth_token(body.token.clone()).await;
// Also persist to file
let token_path = crate::constants::token_file_path();
if let Err(e) = tokio::fs::write(&token_path, &body.token).await {
warn!("Failed to write token file: {e}");
}
let preview = safe_truncate(&body.token, 20);
(
StatusCode::OK,
Json(serde_json::json!({"status": "ok", "token_prefix": preview})),
)
}
async fn handle_usage(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
let stats = state.mitm_store.stats().await;
Json(serde_json::json!({
"mitm": {
"total_requests": stats.total_requests,
"total_input_tokens": stats.total_input_tokens,
"total_output_tokens": stats.total_output_tokens,
"total_cache_read_tokens": stats.total_cache_read_tokens,
"total_cache_creation_tokens": stats.total_cache_creation_tokens,
"total_thinking_output_tokens": stats.total_thinking_output_tokens,
"total_response_output_tokens": stats.total_response_output_tokens,
"total_tokens": stats.total_input_tokens + stats.total_output_tokens,
"per_model": stats.per_model,
}
}))
}
async fn handle_quota(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
let snap = state.quota_store.snapshot().await;
Json(serde_json::to_value(snap).unwrap_or_default())
}