Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f29d14b
feat(protocol): validate /v1/completions request contract
vschandramourya Mar 14, 2026
7e2fa18
Merge branch 'main' into feat/completions-protocol-validation
vschandramourya Mar 14, 2026
f83dc56
Merge branch 'main' into feat/completions-protocol-validation
vschandramourya Mar 15, 2026
20f18eb
Merge branch 'main' into feat/completions-protocol-validation
vschandramourya Mar 19, 2026
38170bc
fix(tests) remove test completion file
vschandramourya Mar 19, 2026
a426df1
fix(protocol): align completion request contract with OpenAI
vschandramourya Mar 19, 2026
a402f0f
Include codex suggestions
vschandramourya Mar 19, 2026
6e4beb6
fix(protocol): align completion validation with OpenAI
vschandramourya Mar 20, 2026
82af9a9
feat(completions): add native gRPC pipeline typing for /v1/completions
vschandramourya Mar 16, 2026
8e3fefd
fix(grpc): simplify harmony request typing and final response formatting
vschandramourya Mar 20, 2026
c6a194e
minor fixes
vschandramourya Mar 20, 2026
6e33908
refactor(grpc): extract wrong-response helpers in pipeline.rs
vschandramourya Mar 20, 2026
f201828
refactor(grpc): resolve pipeline.rs rebase conflict
vschandramourya Mar 23, 2026
1c06f8b
Merge branch 'main' into feat/completions-pipeline-main
vschandramourya Mar 23, 2026
bf6aa56
fix(grpc): align PR branch with current model_id handling
vschandramourya Mar 23, 2026
a0e43e3
fix(grpc) fixing coderabbit requests
vschandramourya Mar 23, 2026
28636db
Merge branch 'main' into feat/completions-pipeline-main
vschandramourya Mar 23, 2026
11432e2
Merge branch 'main' into feat/completions-pipeline-main
vschandramourya Mar 24, 2026
6b4b083
Merge branch 'main' into feat/completions-pipeline-main
vschandramourya Mar 24, 2026
c4188dc
Merge branch 'main' into feat/completions-pipeline-main
vschandramourya Mar 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 118 additions & 33 deletions crates/protocols/src/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,83 +2,105 @@ use std::collections::HashMap;

use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use validator::Validate;

use super::common::*;
use super::{
common::*,
sampling_params::{validate_top_k_value, validate_top_p_value},
};
use crate::validated::Normalizable;

// ============================================================================
// Completions API (v1/completions) - DEPRECATED but still supported
// ============================================================================

#[serde_with::skip_serializing_none]
#[derive(Debug, Clone, Deserialize, Serialize, schemars::JsonSchema)]
#[derive(Debug, Clone, Deserialize, Serialize, Validate, schemars::JsonSchema)]
#[validate(schema(function = "validate_completion_cross_parameters"))]
pub struct CompletionRequest {
/// ID of the model to use (required for OpenAI, optional for some implementations, such as SGLang)
pub model: String,

/// The prompt(s) to generate completions for
#[validate(custom(function = "validate_completion_prompt"))]
pub prompt: StringOrArray,

/// The suffix that comes after a completion of inserted text
pub suffix: Option<String>,
/// Generates `best_of` completions server-side and returns the "best"
#[validate(range(min = 0, max = 20))]
pub best_of: Option<u32>,

/// The maximum number of tokens to generate
pub max_tokens: Option<u32>,
/// Echo back the prompt in addition to the completion
#[serde(default)]
pub echo: bool,

/// What sampling temperature to use, between 0 and 2
pub temperature: Option<f32>,
/// Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far
#[validate(range(min = -2.0, max = 2.0))]
pub frequency_penalty: Option<f32>,

/// An alternative to sampling with temperature (nucleus sampling)
pub top_p: Option<f32>,
/// Modify the likelihood of specified tokens appearing in the completion
pub logit_bias: Option<HashMap<String, f32>>,

/// Include the log probabilities on the `logprobs` most likely tokens
#[validate(range(min = 0, max = 5))]
pub logprobs: Option<u32>,

/// The maximum number of tokens to generate
#[validate(range(min = 0))]
pub max_tokens: Option<u32>,

/// How many completions to generate for each prompt
#[validate(range(min = 1, max = 128))]
pub n: Option<u32>,

/// Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far
#[validate(range(min = -2.0, max = 2.0))]
pub presence_penalty: Option<f32>,

/// If specified, our system will make a best effort to sample deterministically
pub seed: Option<i64>,

/// Up to 4 sequences where the API will stop generating further tokens
#[validate(custom(function = "validate_stop"))]
pub stop: Option<StringOrArray>,

/// Whether to stream back partial progress
#[serde(default)]
pub stream: bool,

/// Options for streaming response
pub stream_options: Option<StreamOptions>,

/// Include the log probabilities on the logprobs most likely tokens
pub logprobs: Option<u32>,

/// Echo back the prompt in addition to the completion
#[serde(default)]
pub echo: bool,

/// Up to 4 sequences where the API will stop generating further tokens
pub stop: Option<StringOrArray>,

/// Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far
pub presence_penalty: Option<f32>,

/// Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far
pub frequency_penalty: Option<f32>,
/// The suffix that comes after a completion of inserted text
pub suffix: Option<String>,

/// Generates best_of completions server-side and returns the "best"
pub best_of: Option<u32>,
/// What sampling temperature to use, between 0 and 2
#[validate(range(min = 0.0, max = 2.0))]
pub temperature: Option<f32>,

/// Modify the likelihood of specified tokens appearing in the completion
pub logit_bias: Option<HashMap<String, f32>>,
/// An alternative to sampling with temperature (nucleus sampling)
#[validate(custom(function = "validate_top_p_value"))]
pub top_p: Option<f32>,

/// A unique identifier representing your end-user
pub user: Option<String>,

/// If specified, our system will make a best effort to sample deterministically
pub seed: Option<i64>,

// -------- Engine Specific Sampling Parameters --------
// =============================================================================
// Engine-Specific Sampling Parameters
// =============================================================================
/// Top-k sampling parameter (-1 to disable)
#[validate(custom(function = "validate_top_k_value"))]
pub top_k: Option<i32>,

/// Min-p nucleus sampling parameter
#[validate(range(min = 0.0, max = 1.0))]
pub min_p: Option<f32>,

/// Minimum number of tokens to generate
#[validate(range(min = 1))]
pub min_tokens: Option<u32>,

/// Repetition penalty for reducing repetitive text
#[validate(range(min = 0.0, max = 2.0))]
pub repetition_penalty: Option<f32>,

/// Regex constraint for output generation
Expand Down Expand Up @@ -123,6 +145,69 @@ pub struct CompletionRequest {
pub other: Map<String, Value>,
}

impl Normalizable for CompletionRequest {}

fn validate_completion_prompt(prompt: &StringOrArray) -> Result<(), validator::ValidationError> {
match prompt {
StringOrArray::String(_) => {}
StringOrArray::Array(arr) => {
if arr.is_empty() {
let mut error = validator::ValidationError::new("prompt_empty");
error.message = Some("prompt array cannot be empty".into());
return Err(error);
}
}
}

Ok(())
}

fn validate_completion_cross_parameters(
req: &CompletionRequest,
) -> Result<(), validator::ValidationError> {
if req.stream_options.is_some() && !req.stream {
let mut error = validator::ValidationError::new("stream_options_requires_stream");
error.message =
Some("The 'stream_options' parameter is only allowed when 'stream' is enabled".into());
return Err(error);
}

if let (Some(min), Some(max)) = (req.min_tokens, req.max_tokens) {
if min > max {
let mut error = validator::ValidationError::new("min_tokens_exceeds_max");
error.message = Some("min_tokens cannot exceed max_tokens".into());
return Err(error);
}
}

let constraint_count =
req.regex.is_some() as u8 + req.ebnf.is_some() as u8 + req.json_schema.is_some() as u8;
if constraint_count > 1 {
let mut error = validator::ValidationError::new("multiple_constraints");
error.message = Some(
"only one structured output constraint (regex, ebnf, or json_schema) can be active at a time"
.into(),
);
return Err(error);
}

if let (Some(best_of), Some(n)) = (req.best_of, req.n) {
if best_of <= n {
let mut error = validator::ValidationError::new("best_of_less_than_n");
error.message = Some("best_of must be greater than n".into());
return Err(error);
}
}

if req.stream && req.best_of.is_some() {
let mut error = validator::ValidationError::new("best_of_not_supported_with_stream");
error.message = Some("best_of is not supported when stream is enabled".into());
return Err(error);
}

Ok(())
}

impl GenerationRequest for CompletionRequest {
fn is_stream(&self) -> bool {
self.stream
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ impl PipelineStage for DispatchMetadataStage {
let request_id = proto_request.request_id().to_string();
let model = match &ctx.input.request_type {
RequestType::Chat(req) => req.model.clone(),
RequestType::Completion(req) => req.model.clone(),
RequestType::Generate(_req) => {
// Generate requests don't have a model field
// Use model_id from input
Expand Down
74 changes: 74 additions & 0 deletions model_gateway/src/routers/grpc/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use llm_tokenizer::{stop::StopSequenceDecoder, traits::Tokenizer, TokenizerRegis
use openai_protocol::{
chat::{ChatCompletionRequest, ChatCompletionResponse},
classify::{ClassifyRequest, ClassifyResponse},
completion::{CompletionRequest, CompletionResponse},
embedding::{EmbeddingRequest, EmbeddingResponse},
generate::{GenerateRequest, GenerateResponse},
messages::{CreateMessageRequest, Message},
Expand Down Expand Up @@ -50,6 +51,7 @@ pub(crate) struct RequestInput {
pub(crate) enum RequestType {
Chat(Arc<ChatCompletionRequest>),
Generate(Arc<GenerateRequest>),
Completion(Arc<CompletionRequest>),
Responses(Arc<ResponsesRequest>),
Embedding(Arc<EmbeddingRequest>),
Classify(Arc<ClassifyRequest>),
Expand All @@ -61,6 +63,7 @@ impl std::fmt::Display for RequestType {
match self {
Self::Chat(_) => write!(f, "Chat"),
Self::Generate(_) => write!(f, "Generate"),
Self::Completion(_) => write!(f, "Completion"),
Self::Responses(_) => write!(f, "Responses"),
Self::Embedding(_) => write!(f, "Embedding"),
Self::Classify(_) => write!(f, "Classify"),
Expand All @@ -69,6 +72,19 @@ impl std::fmt::Display for RequestType {
}
}

impl std::fmt::Display for FinalResponse {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Chat(_) => write!(f, "Chat"),
Self::Generate(_) => write!(f, "Generate"),
Self::Completion(_) => write!(f, "Completion"),
Self::Embedding(_) => write!(f, "Embedding"),
Self::Classify(_) => write!(f, "Classify"),
Self::Messages(_) => write!(f, "Messages"),
}
}
}

/// Shared components (injected once at creation)
pub(crate) struct SharedComponents {
pub tokenizer_registry: Arc<TokenizerRegistry>,
Expand Down Expand Up @@ -254,6 +270,25 @@ impl RequestContext {
}
}

/// Create context for completion request
pub fn for_completion(
request: Arc<CompletionRequest>,
headers: Option<HeaderMap>,
model_id: Option<String>,
components: Arc<SharedComponents>,
) -> Self {
let model_id = model_id.unwrap_or_else(|| request.model.clone());
Self {
input: RequestInput {
request_type: RequestType::Completion(request),
headers,
model_id,
},
components,
state: ProcessingState::default(),
}
}
Comment thread
vschandramourya marked this conversation as resolved.
Resolved

/// Create context for Responses API request
pub fn for_responses(
request: Arc<ResponsesRequest>,
Expand Down Expand Up @@ -374,6 +409,38 @@ impl RequestContext {
}
}

/// Get completion request (panics if not completion)
#[expect(
dead_code,
reason = "ref accessor provided for API completeness alongside Arc accessor"
)]
#[expect(
clippy::panic,
reason = "typed accessor: caller guarantees variant via RequestType construction"
)]
pub fn completion_request(&self) -> &CompletionRequest {
match &self.input.request_type {
RequestType::Completion(req) => req.as_ref(),
_ => panic!("Expected completion request"),
}
}

/// Get Arc clone of completion request (panics if not completion)
#[expect(
dead_code,
reason = "Arc accessor is introduced before later stacked PRs use it from completion stages"
)]
#[expect(
clippy::panic,
reason = "typed accessor: caller guarantees variant via RequestType construction"
)]
pub fn completion_request_arc(&self) -> Arc<CompletionRequest> {
match &self.input.request_type {
RequestType::Completion(req) => Arc::clone(req),
_ => panic!("Expected completion request"),
}
}

/// Get Arc clone of responses request (panics if not responses)
#[expect(
clippy::panic,
Expand Down Expand Up @@ -419,6 +486,7 @@ impl RequestContext {
match &self.input.request_type {
RequestType::Chat(req) => req.stream,
RequestType::Generate(req) => req.stream,
RequestType::Completion(req) => req.stream,
RequestType::Responses(req) => req.stream.unwrap_or(false),
RequestType::Messages(req) => req.stream.unwrap_or(false),
RequestType::Embedding(_) => false, // Embeddings are never streaming
Expand Down Expand Up @@ -597,10 +665,16 @@ pub(crate) enum ExecutionResult {

/// Final processed response
#[derive(Debug)]
#[expect(
dead_code,
reason = "Completion responses are typed in the pipeline before a later stage constructs them"
)]
pub(crate) enum FinalResponse {
Chat(ChatCompletionResponse),
/// Generate response is a Vec of GenerateResponse (n=1 returns single item, n>1 returns multiple)
Generate(Vec<GenerateResponse>),
/// Completion response (OpenAI /v1/completions format)
Completion(CompletionResponse),
/// Embedding response
Embedding(EmbeddingResponse),
/// Classification response
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ impl PipelineStage for HarmonyRequestBuildingStage {
RequestType::Chat(_) => format!("chatcmpl-{}", Uuid::now_v7()),
RequestType::Responses(_) => format!("responses-{}", Uuid::now_v7()),
request_type @ (RequestType::Generate(_)
| RequestType::Completion(_)
| RequestType::Embedding(_)
| RequestType::Classify(_)
| RequestType::Messages(_)) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ impl PipelineStage for HarmonyResponseProcessingStage {
Ok(None)
}
request_type @ (RequestType::Generate(_)
| RequestType::Completion(_)
| RequestType::Embedding(_)
| RequestType::Classify(_)
| RequestType::Messages(_)) => {
Expand Down
Loading
Loading