From 495ac2cf616cf9e10adecf63d63fb800b0fb6235 Mon Sep 17 00:00:00 2001 From: jcameron Date: Mon, 28 Sep 2026 11:23:01 -0300 Subject: [PATCH 1/4] fix(cloud-functions): reject LLM functions on classic invocation auth Classic invocation (pexec, exec, and the function host form) of an LLM-type function published a request to a JetStream queue that LLM workers never consume. The caller waited out the 60 second default poll window plus a 2 second grace period and received an empty 504. AuthClientInvocation now rejects functions whose resolved versions are LLM with INVALID_ARGUMENT, which http-invocation already maps to a 400 problem-details response. Access checks still run first, so callers without access get 404 and the function type is not revealed. Tests cover the gRPC contract in GrpcInvocationServiceTest and, through the NVCF API mock, the http-invocation behavior on every classic entry point, including that no request stream is created. Closes #2111 Co-Authored-By: Claude Opus 5.5 Signed-off-by: jcameron --- .../nvcf/grpc/GrpcInvocationService.java | 24 +++++ .../nvcf/grpc/GrpcInvocationServiceTest.java | 79 ++++++++++++++ .../crates/server/tests/mocks/mod.rs | 16 +++ .../server/tests/mocks/nvcf_api_mock.rs | 11 ++ .../crates/server/tests/test_error_codes.rs | 100 +++++++++++++++++- 5 files changed, 229 insertions(+), 1 deletion(-) diff --git a/src/control-plane-services/cloud-functions/nvcf-core/src/main/java/com/nvidia/nvcf/grpc/GrpcInvocationService.java b/src/control-plane-services/cloud-functions/nvcf-core/src/main/java/com/nvidia/nvcf/grpc/GrpcInvocationService.java index 577ceadedc..0d6dd80b44 100644 --- a/src/control-plane-services/cloud-functions/nvcf-core/src/main/java/com/nvidia/nvcf/grpc/GrpcInvocationService.java +++ b/src/control-plane-services/cloud-functions/nvcf-core/src/main/java/com/nvidia/nvcf/grpc/GrpcInvocationService.java @@ -20,8 +20,10 @@ import static com.nvidia.nvcf.util.NvcfConstants.SCOPE_INVOKE_FUNCTION; import com.google.common.annotations.VisibleForTesting; +import com.nvidia.boot.exceptions.BadRequestException; import com.nvidia.boot.exceptions.UnauthorizedException; import com.nvidia.nvcf.configuration.exceptions.InvalidInvocationException; +import com.nvidia.nvcf.persistence.function.entity.FunctionType; import com.nvidia.nvcf.proto.ClientInvokeRequest; import com.nvidia.nvcf.proto.ClientInvokeResponse; import com.nvidia.nvcf.proto.ClientInvokeResponse.FunctionVersion; @@ -46,6 +48,10 @@ @RequiredArgsConstructor public class GrpcInvocationService extends InvocationImplBase { + private static final String MESG_LLM_FUNCTION_NOT_INVOCABLE = + "Function id '%s': LLM functions cannot be invoked through this endpoint. " + + "Use the LLM API instead."; + private final GrpcAuthService grpcAuthService; private final AccountService accountService; private final FunctionInvocationValidationService functionInvocationValidationService; @@ -68,6 +74,9 @@ public void authClientInvocation( ncaId, functionId, functionVersionId); + // LLM workers do not consume the request queues this path publishes to, so a request + // would wait out the poll window and time out. Fail fast instead. + rejectLlmFunctions(ncaId, functionId, functions); // picking the first function version for function level info. // all function versions will be of the same function. @@ -154,4 +163,19 @@ private List lookupAndValidateAccess( } } + private static void rejectLlmFunctions( + String ncaId, + UUID functionId, + List functions) { + var hasLlmVersion = functions.stream() + .map(FunctionContext::targetFunction) + .anyMatch(function -> function.getFunctionType() == FunctionType.LLM); + if (hasLlmVersion) { + var mesg = MESG_LLM_FUNCTION_NOT_INVOCABLE.formatted(functionId); + log.warn("Rejecting classic invocation of LLM function: ncaId={}, functionId={}", + ncaId, functionId); + throw new InvalidInvocationException(ncaId, new BadRequestException(mesg)); + } + } + } diff --git a/src/control-plane-services/cloud-functions/nvcf-core/src/test/java/com/nvidia/nvcf/grpc/GrpcInvocationServiceTest.java b/src/control-plane-services/cloud-functions/nvcf-core/src/test/java/com/nvidia/nvcf/grpc/GrpcInvocationServiceTest.java index 6015dfd72c..a239a5aaac 100644 --- a/src/control-plane-services/cloud-functions/nvcf-core/src/test/java/com/nvidia/nvcf/grpc/GrpcInvocationServiceTest.java +++ b/src/control-plane-services/cloud-functions/nvcf-core/src/test/java/com/nvidia/nvcf/grpc/GrpcInvocationServiceTest.java @@ -47,6 +47,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.nvidia.nvcf.persistence.function.entity.FunctionType; import com.nvidia.nvcf.persistence.function.entity.RateLimitUdt; import com.nvidia.nvcf.proto.ClientInvokeRequest; import com.nvidia.nvcf.proto.ClientInvokeResponse; @@ -1088,4 +1089,82 @@ void errorResponseIncludesNcaIdInMetadata() { channel.shutdownNow(); } + Stream llmFunctionVersionArgs() { + return Stream.of( + Arguments.of(TEST_VERSION_ID_1.toString()), + Arguments.of((String) null)); + } + + /** + * LLM workers do not consume the request queues used by classic invocation, so the auth + * call must reject LLM functions up front instead of letting the request time out. + */ + @ParameterizedTest + @MethodSource("llmFunctionVersionArgs") + void rejectsLlmFunctionForClassicInvocation(@Nullable String versionId) { + setFunctionActive(TEST_FUNCTION_ID, TEST_VERSION_ID_1); + setFunctionType(TEST_FUNCTION_ID, TEST_VERSION_ID_1, FunctionType.LLM); + var clientAuth = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_INVOKE_FUNCTION), 100); + var ncaIdKey = Metadata.Key.of(TAG_NCA_ID, Metadata.ASCII_STRING_MARSHALLER); + + assertThatThrownBy(() -> functionAuth(clientAuth, TEST_FUNCTION_ID.toString(), versionId)) + .isInstanceOf(StatusRuntimeException.class) + .satisfies(thrown -> { + var exception = (StatusRuntimeException) thrown; + assertThat(exception.getStatus().getCode()) + .isEqualTo(Status.INVALID_ARGUMENT.getCode()); + assertThat(exception.getStatus().getDescription()) + .contains(TEST_FUNCTION_ID.toString()) + .contains("LLM functions cannot be invoked through this endpoint"); + assertThat(exception.getTrailers()).isNotNull(); + assertThat(exception.getTrailers().get(ncaIdKey)).isEqualTo(TEST_NCA_ID); + }); + } + + @Test + void rejectsLlmFunctionForClassicAdminInvocation() { + setFunctionActive(TEST_FUNCTION_ID, TEST_VERSION_ID_1); + setFunctionType(TEST_FUNCTION_ID, TEST_VERSION_ID_1, FunctionType.LLM); + var adminAuth = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_ADMIN_SUBJECT, + List.of(ADMIN_SCOPE_INVOKE_FUNCTION), 100); + + assertThatThrownBy(() -> functionAdminAuth(adminAuth, TEST_FUNCTION_ID.toString(), + TEST_VERSION_ID_1.toString())) + .isInstanceOf(StatusRuntimeException.class) + .extracting(thrown -> ((StatusRuntimeException) thrown).getStatus().getCode()) + .isEqualTo(Status.INVALID_ARGUMENT.getCode()); + } + + @Test + void llmFunctionWithoutAccessStillReturnsNotFound() { + // TEST_FUNCTION_ID_2 belongs to another account. Access checks must run first so the + // function type of an inaccessible function is not revealed. + setFunctionActive(TEST_FUNCTION_ID_2, TEST_VERSION_ID_2); + setFunctionType(TEST_FUNCTION_ID_2, TEST_VERSION_ID_2, FunctionType.LLM); + var clientAuth = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_INVOKE_FUNCTION), 100); + + assertThatThrownBy(() -> functionAuth(clientAuth, TEST_FUNCTION_ID_2.toString(), + TEST_VERSION_ID_2.toString())) + .isInstanceOf(StatusRuntimeException.class) + .extracting(thrown -> ((StatusRuntimeException) thrown).getStatus().getCode()) + .isEqualTo(Status.NOT_FOUND.getCode()); + } + + @Test + void allowsStreamingFunctionForClassicInvocation() { + setFunctionActive(TEST_FUNCTION_ID, TEST_VERSION_ID_1); + setFunctionType(TEST_FUNCTION_ID, TEST_VERSION_ID_1, FunctionType.STREAMING); + var clientAuth = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_INVOKE_FUNCTION), 100); + + var clientInvokeResponse = functionAuth( + clientAuth, TEST_FUNCTION_ID.toString(), TEST_VERSION_ID_1.toString()); + + assertThat(clientInvokeResponse.getFunctionVersionsList()) + .extracting(FunctionVersion::getFunctionVersionId) + .containsExactly(TEST_VERSION_ID_1.toString()); + } + } diff --git a/src/invocation-plane-services/http-invocation/crates/server/tests/mocks/mod.rs b/src/invocation-plane-services/http-invocation/crates/server/tests/mocks/mod.rs index 593b44a66f..40a1e8d06a 100644 --- a/src/invocation-plane-services/http-invocation/crates/server/tests/mocks/mod.rs +++ b/src/invocation-plane-services/http-invocation/crates/server/tests/mocks/mod.rs @@ -45,6 +45,10 @@ pub const VERSION_ID_1: Uuid = uuid!("26597542-1782-4a18-aa02-504ba0598202"); pub const VERSION_ID_2: Uuid = uuid!("49331123-201a-407d-9cbc-7bb328fd0295"); pub const VERSION_ID_3: Uuid = uuid!("4117e32b-1b96-4be2-8cd2-9da4047daa05"); pub const VERSION_ID_4: Uuid = uuid!("2bf046f7-37c1-40b8-b2c2-8f8d260f7645"); +#[allow(unused)] +pub const LLM_FUNCTION_ID: Uuid = uuid!("5d0c8a3e-9f4b-4e62-8a1d-3b7e2c9f0a14"); +#[allow(unused)] +pub const LLM_VERSION_ID: Uuid = uuid!("e2a7b4c1-6d3f-4a85-9c02-7f1e8b5d3a96"); pub const LOCALSTACK_REGION: &str = "us-east-1"; pub const ASSETS_BUCKET: &str = "assets-bucket"; pub const RESULTS_BUCKET: &str = "results-bucket"; @@ -141,6 +145,7 @@ async fn mock_nvcf_api() -> ApiMockServer { functions: vec![VERSION_ID_1, VERSION_ID_2], has_rate_limit: false, sync_check: false, + is_llm: false, }, ), // FUNCTION_ID_2_RATELIMIT_SYNC has rate limit and sync check @@ -150,6 +155,7 @@ async fn mock_nvcf_api() -> ApiMockServer { functions: vec![VERSION_ID_3], has_rate_limit: true, sync_check: true, + is_llm: false, }, ), // FUNCTION_ID_3_RATELIMIT_ASYNC has rate limit and async check @@ -159,6 +165,16 @@ async fn mock_nvcf_api() -> ApiMockServer { functions: vec![VERSION_ID_4], has_rate_limit: true, sync_check: false, + is_llm: false, + }, + ), + ( + LLM_FUNCTION_ID, + FunctionMetadata { + functions: vec![LLM_VERSION_ID], + has_rate_limit: false, + sync_check: false, + is_llm: true, }, ), ] diff --git a/src/invocation-plane-services/http-invocation/crates/server/tests/mocks/nvcf_api_mock.rs b/src/invocation-plane-services/http-invocation/crates/server/tests/mocks/nvcf_api_mock.rs index 8b5b85613e..a55b4a570f 100644 --- a/src/invocation-plane-services/http-invocation/crates/server/tests/mocks/nvcf_api_mock.rs +++ b/src/invocation-plane-services/http-invocation/crates/server/tests/mocks/nvcf_api_mock.rs @@ -44,6 +44,8 @@ pub struct FunctionMetadata { pub functions: Vec, pub has_rate_limit: bool, pub sync_check: bool, + /// LLM functions are rejected by nvcf-service for classic invocation. + pub is_llm: bool, } #[derive(Debug)] @@ -82,6 +84,15 @@ impl ApiMock { // Include nca_id in error metadata (matching nvcf-service GrpcNcaIdException behavior) Self::status_with_nca_id(tonic::Code::NotFound, "function not found", &client.nca_id) })?; + if function_metadata.is_llm { + return Err(Self::status_with_nca_id( + tonic::Code::InvalidArgument, + &format!( + "Function id '{function_id}': LLM functions cannot be invoked through this endpoint. Use the LLM API instead." + ), + &client.nca_id, + )); + } let requested_function_version_id = request.function_version_id; Ok(Response::new(ClientInvokeResponse { function_id: function_id.into(), diff --git a/src/invocation-plane-services/http-invocation/crates/server/tests/test_error_codes.rs b/src/invocation-plane-services/http-invocation/crates/server/tests/test_error_codes.rs index f990040206..9ba8726230 100644 --- a/src/invocation-plane-services/http-invocation/crates/server/tests/test_error_codes.rs +++ b/src/invocation-plane-services/http-invocation/crates/server/tests/test_error_codes.rs @@ -21,14 +21,18 @@ use axum::{ http::{self, header, HeaderValue, Method, StatusCode}, }; use futures::future; +use http_body_util::BodyExt; use mocks::{ fixtures, nvcf_worker_mock::{ DefaultWorkHandler, DroppableBackgroundWorker, FailedWorkHandler, Worker, WorkerProperties, }, - API_KEY, FUNCTION_ID, INSTANCE_ID, VERSION_ID_1, + API_KEY, FUNCTION_ID, INSTANCE_ID, LLM_FUNCTION_ID, LLM_VERSION_ID, VERSION_ID_1, }; use nvcf_invocation_service::app::app; +use nvcf_invocation_service::nats::NatsService; +use nvcf_invocation_service::settings::GrpcClientConfig; +use problem_details::ProblemDetails; use std::time::{Duration, Instant}; use tokio::{task::JoinHandle, time::timeout}; use tower::{Service, ServiceExt}; @@ -342,3 +346,97 @@ async fn test_delayed_worker_start() -> anyhow::Result<()> { } Ok(()) } + +/// LLM functions are rejected by the auth call, so classic invocation must fail fast with a 400 +/// on every entry point and must not publish a request that no worker will consume. +#[tokio::test] +async fn test_llm_function_rejected_without_publishing() -> anyhow::Result<()> { + let (_localstack, _nats, _mock_nvcf_api, config) = fixtures().await; + let mut app = app(config.clone(), None).await?; + let app = ServiceExt::>::ready(&mut app).await?; + + let requests = [ + http::Request::builder() + .method(Method::POST) + .uri(format!( + "/v2/nvcf/pexec/functions/{LLM_FUNCTION_ID}/versions/{LLM_VERSION_ID}" + )) + .header(header::AUTHORIZATION, format!("Bearer {API_KEY}")) + .body(Body::from("a body"))?, + http::Request::builder() + .method(Method::POST) + .uri(format!("/v2/nvcf/pexec/functions/{LLM_FUNCTION_ID}")) + .header(header::AUTHORIZATION, format!("Bearer {API_KEY}")) + .body(Body::from("a body"))?, + http::Request::builder() + .method(Method::POST) + .uri(format!("/v2/nvcf/exec/functions/{LLM_FUNCTION_ID}")) + .header(header::AUTHORIZATION, format!("Bearer {API_KEY}")) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"requestBody":{"input":"hi"}}"#))?, + // host form, as used by ./v1/responses + http::Request::builder() + .method(Method::POST) + .uri("/v1/responses") + .header( + header::HOST, + format!("{LLM_FUNCTION_ID}.example.nvidia.com"), + ) + .header(header::AUTHORIZATION, format!("Bearer {API_KEY}")) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"input":"hi"}"#))?, + ]; + + for request in requests { + let uri = request.uri().clone(); + let start = Instant::now(); + let response = timeout(Duration::from_secs(10), app.call(request)) + .await + .unwrap_or_else(|_| panic!("{uri}: request should not wait for a worker"))?; + assert!( + start.elapsed() < Duration::from_secs(5), + "{uri}: took {:?}", + start.elapsed() + ); + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{uri}"); + assert_eq!( + response.headers().get(header::CONTENT_TYPE), + Some(&HeaderValue::from_static("application/problem+json")), + "{uri}" + ); + let body = response.into_body().collect().await?.to_bytes(); + let problem: ProblemDetails = serde_json::from_slice(&body)?; + assert_eq!( + problem.status, + Some(StatusCode::BAD_REQUEST), + "{uri}: {problem:?}" + ); + assert!( + problem + .detail + .as_deref() + .is_some_and(|detail| detail.contains("LLM functions cannot be invoked")), + "{uri}: {problem:?}" + ); + } + + // publishing creates the request stream on demand, so its absence proves nothing was queued + let nats_service = NatsService::new( + &config.nats_properties, + "http://dummy.localhost", + None, + &GrpcClientConfig::default(), + ) + .await?; + let stream_name = nats_service.request_stream_name(LLM_VERSION_ID); + assert!( + nats_service + .jetstream() + .get_stream(&stream_name) + .await + .is_err(), + "no request stream should exist for the rejected LLM function" + ); + + Ok(()) +} From 6e6d118d12bae513b56cd5ff20f50ad2e170d100 Mon Sep 17 00:00:00 2001 From: jcameron Date: Mon, 28 Sep 2026 11:30:35 -0300 Subject: [PATCH 2/4] fix(cloud-functions): return 404 for classic invocation of LLM functions Match the reverse case, where a classic function called through the LLM API returns 404. Co-Authored-By: Claude Opus 5.5 Signed-off-by: jcameron --- .../com/nvidia/nvcf/grpc/GrpcInvocationService.java | 4 ++-- .../nvidia/nvcf/grpc/GrpcInvocationServiceTest.java | 11 +++++++---- .../crates/server/tests/mocks/nvcf_api_mock.rs | 2 +- .../crates/server/tests/test_error_codes.rs | 6 +++--- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/control-plane-services/cloud-functions/nvcf-core/src/main/java/com/nvidia/nvcf/grpc/GrpcInvocationService.java b/src/control-plane-services/cloud-functions/nvcf-core/src/main/java/com/nvidia/nvcf/grpc/GrpcInvocationService.java index 0d6dd80b44..51b1347a21 100644 --- a/src/control-plane-services/cloud-functions/nvcf-core/src/main/java/com/nvidia/nvcf/grpc/GrpcInvocationService.java +++ b/src/control-plane-services/cloud-functions/nvcf-core/src/main/java/com/nvidia/nvcf/grpc/GrpcInvocationService.java @@ -20,7 +20,7 @@ import static com.nvidia.nvcf.util.NvcfConstants.SCOPE_INVOKE_FUNCTION; import com.google.common.annotations.VisibleForTesting; -import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.NotFoundException; import com.nvidia.boot.exceptions.UnauthorizedException; import com.nvidia.nvcf.configuration.exceptions.InvalidInvocationException; import com.nvidia.nvcf.persistence.function.entity.FunctionType; @@ -174,7 +174,7 @@ private static void rejectLlmFunctions( var mesg = MESG_LLM_FUNCTION_NOT_INVOCABLE.formatted(functionId); log.warn("Rejecting classic invocation of LLM function: ncaId={}, functionId={}", ncaId, functionId); - throw new InvalidInvocationException(ncaId, new BadRequestException(mesg)); + throw new InvalidInvocationException(ncaId, new NotFoundException(mesg)); } } diff --git a/src/control-plane-services/cloud-functions/nvcf-core/src/test/java/com/nvidia/nvcf/grpc/GrpcInvocationServiceTest.java b/src/control-plane-services/cloud-functions/nvcf-core/src/test/java/com/nvidia/nvcf/grpc/GrpcInvocationServiceTest.java index a239a5aaac..6918bf1af2 100644 --- a/src/control-plane-services/cloud-functions/nvcf-core/src/test/java/com/nvidia/nvcf/grpc/GrpcInvocationServiceTest.java +++ b/src/control-plane-services/cloud-functions/nvcf-core/src/test/java/com/nvidia/nvcf/grpc/GrpcInvocationServiceTest.java @@ -1113,7 +1113,7 @@ void rejectsLlmFunctionForClassicInvocation(@Nullable String versionId) { .satisfies(thrown -> { var exception = (StatusRuntimeException) thrown; assertThat(exception.getStatus().getCode()) - .isEqualTo(Status.INVALID_ARGUMENT.getCode()); + .isEqualTo(Status.NOT_FOUND.getCode()); assertThat(exception.getStatus().getDescription()) .contains(TEST_FUNCTION_ID.toString()) .contains("LLM functions cannot be invoked through this endpoint"); @@ -1133,7 +1133,7 @@ void rejectsLlmFunctionForClassicAdminInvocation() { TEST_VERSION_ID_1.toString())) .isInstanceOf(StatusRuntimeException.class) .extracting(thrown -> ((StatusRuntimeException) thrown).getStatus().getCode()) - .isEqualTo(Status.INVALID_ARGUMENT.getCode()); + .isEqualTo(Status.NOT_FOUND.getCode()); } @Test @@ -1148,8 +1148,11 @@ void llmFunctionWithoutAccessStillReturnsNotFound() { assertThatThrownBy(() -> functionAuth(clientAuth, TEST_FUNCTION_ID_2.toString(), TEST_VERSION_ID_2.toString())) .isInstanceOf(StatusRuntimeException.class) - .extracting(thrown -> ((StatusRuntimeException) thrown).getStatus().getCode()) - .isEqualTo(Status.NOT_FOUND.getCode()); + .satisfies(thrown -> { + var status = ((StatusRuntimeException) thrown).getStatus(); + assertThat(status.getCode()).isEqualTo(Status.NOT_FOUND.getCode()); + assertThat(status.getDescription()).doesNotContain("LLM"); + }); } @Test diff --git a/src/invocation-plane-services/http-invocation/crates/server/tests/mocks/nvcf_api_mock.rs b/src/invocation-plane-services/http-invocation/crates/server/tests/mocks/nvcf_api_mock.rs index a55b4a570f..5569a4351f 100644 --- a/src/invocation-plane-services/http-invocation/crates/server/tests/mocks/nvcf_api_mock.rs +++ b/src/invocation-plane-services/http-invocation/crates/server/tests/mocks/nvcf_api_mock.rs @@ -86,7 +86,7 @@ impl ApiMock { })?; if function_metadata.is_llm { return Err(Self::status_with_nca_id( - tonic::Code::InvalidArgument, + tonic::Code::NotFound, &format!( "Function id '{function_id}': LLM functions cannot be invoked through this endpoint. Use the LLM API instead." ), diff --git a/src/invocation-plane-services/http-invocation/crates/server/tests/test_error_codes.rs b/src/invocation-plane-services/http-invocation/crates/server/tests/test_error_codes.rs index 9ba8726230..814d5f1325 100644 --- a/src/invocation-plane-services/http-invocation/crates/server/tests/test_error_codes.rs +++ b/src/invocation-plane-services/http-invocation/crates/server/tests/test_error_codes.rs @@ -347,7 +347,7 @@ async fn test_delayed_worker_start() -> anyhow::Result<()> { Ok(()) } -/// LLM functions are rejected by the auth call, so classic invocation must fail fast with a 400 +/// LLM functions are rejected by the auth call, so classic invocation must fail fast with a 404 /// on every entry point and must not publish a request that no worker will consume. #[tokio::test] async fn test_llm_function_rejected_without_publishing() -> anyhow::Result<()> { @@ -398,7 +398,7 @@ async fn test_llm_function_rejected_without_publishing() -> anyhow::Result<()> { "{uri}: took {:?}", start.elapsed() ); - assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{uri}"); + assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}"); assert_eq!( response.headers().get(header::CONTENT_TYPE), Some(&HeaderValue::from_static("application/problem+json")), @@ -408,7 +408,7 @@ async fn test_llm_function_rejected_without_publishing() -> anyhow::Result<()> { let problem: ProblemDetails = serde_json::from_slice(&body)?; assert_eq!( problem.status, - Some(StatusCode::BAD_REQUEST), + Some(StatusCode::NOT_FOUND), "{uri}: {problem:?}" ); assert!( From ff02bf4745d1035c479b69f84dc71105a2f4080f Mon Sep 17 00:00:00 2001 From: jcameron Date: Mon, 28 Sep 2026 11:56:32 -0300 Subject: [PATCH 3/4] fix(cloud-functions): keep classic versions of mixed LLM families invocable Version type uniformity is only enforced when a new version is created, so older families may mix LLM and classic versions. Rejecting the whole family when any resolved version was LLM would break versionless classic invocation of such a family. Drop LLM versions from the classic auth response instead, and return 404 only when no invocable version remains. Co-Authored-By: Claude Opus 5.5 Signed-off-by: jcameron --- .../nvcf/grpc/GrpcInvocationService.java | 24 +++++++++-------- .../nvcf/grpc/GrpcInvocationServiceTest.java | 27 +++++++++++++++++++ .../BaseFunctionInvocationTest.java | 6 +++++ 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/control-plane-services/cloud-functions/nvcf-core/src/main/java/com/nvidia/nvcf/grpc/GrpcInvocationService.java b/src/control-plane-services/cloud-functions/nvcf-core/src/main/java/com/nvidia/nvcf/grpc/GrpcInvocationService.java index 51b1347a21..ab0f171938 100644 --- a/src/control-plane-services/cloud-functions/nvcf-core/src/main/java/com/nvidia/nvcf/grpc/GrpcInvocationService.java +++ b/src/control-plane-services/cloud-functions/nvcf-core/src/main/java/com/nvidia/nvcf/grpc/GrpcInvocationService.java @@ -70,13 +70,14 @@ public void authClientInvocation( UUID.fromString(request.getFunctionVersionId()) : null; var ncaId = request.hasTargetNcaId() ? request.getTargetNcaId() : accountService.getNcaId(authentication); - var functions = lookupAndValidateAccess(authentication, - ncaId, - functionId, - functionVersionId); // LLM workers do not consume the request queues this path publishes to, so a request - // would wait out the poll window and time out. Fail fast instead. - rejectLlmFunctions(ncaId, functionId, functions); + // would wait out the poll window and time out. Only return versions that can serve it. + var functions = withoutLlmVersions(ncaId, + functionId, + lookupAndValidateAccess(authentication, + ncaId, + functionId, + functionVersionId)); // picking the first function version for function level info. // all function versions will be of the same function. @@ -163,19 +164,20 @@ private List lookupAndValidateAccess( } } - private static void rejectLlmFunctions( + private static List withoutLlmVersions( String ncaId, UUID functionId, List functions) { - var hasLlmVersion = functions.stream() - .map(FunctionContext::targetFunction) - .anyMatch(function -> function.getFunctionType() == FunctionType.LLM); - if (hasLlmVersion) { + var invocable = functions.stream() + .filter(context -> context.targetFunction().getFunctionType() != FunctionType.LLM) + .toList(); + if (invocable.isEmpty()) { var mesg = MESG_LLM_FUNCTION_NOT_INVOCABLE.formatted(functionId); log.warn("Rejecting classic invocation of LLM function: ncaId={}, functionId={}", ncaId, functionId); throw new InvalidInvocationException(ncaId, new NotFoundException(mesg)); } + return invocable; } } diff --git a/src/control-plane-services/cloud-functions/nvcf-core/src/test/java/com/nvidia/nvcf/grpc/GrpcInvocationServiceTest.java b/src/control-plane-services/cloud-functions/nvcf-core/src/test/java/com/nvidia/nvcf/grpc/GrpcInvocationServiceTest.java index 6918bf1af2..f44f00b4d6 100644 --- a/src/control-plane-services/cloud-functions/nvcf-core/src/test/java/com/nvidia/nvcf/grpc/GrpcInvocationServiceTest.java +++ b/src/control-plane-services/cloud-functions/nvcf-core/src/test/java/com/nvidia/nvcf/grpc/GrpcInvocationServiceTest.java @@ -44,6 +44,7 @@ import static com.nvidia.nvcf.util.TestConstants.TEST_VERSION_ID_1; import static com.nvidia.nvcf.util.TestConstants.TEST_VERSION_ID_2; import static com.nvidia.nvcf.util.TestConstants.TEST_VERSION_ID_3; +import static com.nvidia.nvcf.util.TestConstants.TEST_VERSION_ID_5; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -1155,6 +1156,32 @@ void llmFunctionWithoutAccessStillReturnsNotFound() { }); } + /** + * Families created before type uniformity was enforced may mix LLM and classic versions. + * A versionless classic request must still route to the classic versions. + */ + @Test + void mixedFamilyReturnsOnlyClassicVersions() { + createFunctionVersion(TEST_FUNCTION_ID, TEST_VERSION_ID_5); + setFunctionActive(TEST_FUNCTION_ID, TEST_VERSION_ID_1); + setFunctionActive(TEST_FUNCTION_ID, TEST_VERSION_ID_5); + setFunctionType(TEST_FUNCTION_ID, TEST_VERSION_ID_5, FunctionType.LLM); + var clientAuth = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT, + List.of(SCOPE_INVOKE_FUNCTION), 100); + + var clientInvokeResponse = functionAuth(clientAuth, TEST_FUNCTION_ID.toString(), null); + assertThat(clientInvokeResponse.getFunctionVersionsList()) + .extracting(FunctionVersion::getFunctionVersionId) + .containsExactly(TEST_VERSION_ID_1.toString()); + + // explicitly targeting the LLM version is still rejected + assertThatThrownBy(() -> functionAuth(clientAuth, TEST_FUNCTION_ID.toString(), + TEST_VERSION_ID_5.toString())) + .isInstanceOf(StatusRuntimeException.class) + .extracting(thrown -> ((StatusRuntimeException) thrown).getStatus().getCode()) + .isEqualTo(Status.NOT_FOUND.getCode()); + } + @Test void allowsStreamingFunctionForClassicInvocation() { setFunctionActive(TEST_FUNCTION_ID, TEST_VERSION_ID_1); diff --git a/src/control-plane-services/cloud-functions/nvcf-core/src/test/java/com/nvidia/nvcf/rest/function/invocation/BaseFunctionInvocationTest.java b/src/control-plane-services/cloud-functions/nvcf-core/src/test/java/com/nvidia/nvcf/rest/function/invocation/BaseFunctionInvocationTest.java index bf036d0836..5e1e912d94 100644 --- a/src/control-plane-services/cloud-functions/nvcf-core/src/test/java/com/nvidia/nvcf/rest/function/invocation/BaseFunctionInvocationTest.java +++ b/src/control-plane-services/cloud-functions/nvcf-core/src/test/java/com/nvidia/nvcf/rest/function/invocation/BaseFunctionInvocationTest.java @@ -333,6 +333,12 @@ protected FunctionEntity setFunctionRateLimit(UUID functionId, UUID functionVers return functionsRepository.save(entity); } + protected void createFunctionVersion(UUID functionId, UUID functionVersionId) { + testService.createTestFunctionEntity(functionId, functionVersionId, + TEST_NCA_ID, TEST_FUNCTION_NAME, + FunctionStatus.DEPLOYING); + } + protected FunctionEntity setFunctionActive(UUID functionId, UUID functionVersionId) { return setFunctionStatus(functionId, functionVersionId, FunctionStatus.ACTIVE); } From efc0b5c91cc6bc4ca7605ef87eadb5a07a24254e Mon Sep 17 00:00:00 2001 From: jcameron Date: Mon, 28 Sep 2026 13:29:22 -0300 Subject: [PATCH 4/4] test(http-invocation): require stream-not-found in LLM rejection test The stream lookup previously accepted any error, so a connection or timeout failure could pass the test without proving nothing was queued. Accept only the JetStream stream-not-found error and propagate others. Co-Authored-By: Claude Opus 5.5 Signed-off-by: jcameron --- .../crates/server/tests/test_error_codes.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/invocation-plane-services/http-invocation/crates/server/tests/test_error_codes.rs b/src/invocation-plane-services/http-invocation/crates/server/tests/test_error_codes.rs index 814d5f1325..ebfc225ecf 100644 --- a/src/invocation-plane-services/http-invocation/crates/server/tests/test_error_codes.rs +++ b/src/invocation-plane-services/http-invocation/crates/server/tests/test_error_codes.rs @@ -16,6 +16,7 @@ mod mocks; use crate::mocks::nvcf_worker_mock::PublishMode; +use async_nats::jetstream::{context::GetStreamErrorKind, ErrorCode}; use axum::{ body::Body, http::{self, header, HeaderValue, Method, StatusCode}, @@ -429,14 +430,14 @@ async fn test_llm_function_rejected_without_publishing() -> anyhow::Result<()> { ) .await?; let stream_name = nats_service.request_stream_name(LLM_VERSION_ID); - assert!( - nats_service - .jetstream() - .get_stream(&stream_name) - .await - .is_err(), - "no request stream should exist for the rejected LLM function" - ); + match nats_service.jetstream().get_stream(&stream_name).await { + Ok(_) => panic!("no request stream should exist for the rejected LLM function"), + Err(err) => match err.kind() { + GetStreamErrorKind::JetStream(js_err) + if js_err.error_code() == ErrorCode::STREAM_NOT_FOUND => {} + _ => return Err(err.into()), + }, + } Ok(()) }