From c7221de14786e827f372926c5754c0c5046e2f7d Mon Sep 17 00:00:00 2001 From: Alex Khakhlyuk Date: Thu, 3 Sep 2026 15:18:28 +0000 Subject: [PATCH] Add direct-mode bundle support for AI Gateway model_service Adds direct-engine Databricks Asset Bundle support for the Unity Catalog AI Gateway model service securable (model_services), so it can be declared and deployed from databricks.yml. First of a stacked split of #6410 (mcp_services and model_provider_services follow). Direct engine only. - bundle/config/resources/model_service.go: flat ConfigResource exposing the immutable parent + model_service_id identity plus the mutable comment/config body. - bundle/direct/dresources/model_service.go: CRUD onto the AiGateway service; DoRead reconstructs identity from the server-derived name so it participates in drift detection. - resources.yml: parent + model_service_id as immutable id fields; the server-computed output-only config fields (inference_table.table/is_deleted and routing.destinations[*]/fallback.destinations[*].is_deleted) suppressed to avoid phantom drift. - Registration, testserver implementation, regenerated schema, acceptance (basic + remote-delete + bind), and an invariant config. Scope is strictly model_service: no changes to jobs suppression or other resources. Owner is deferred (the API returns effective_owner); permissions use grants; no Catalog Explorer URL is wired up yet. Co-authored-by: Isaac --- .../bind/model_service/databricks.yml | 10 + .../bind/model_service/out.test.toml | 2 + .../deployment/bind/model_service/output.txt | 30 ++ .../deployment/bind/model_service/script | 5 + .../deployment/bind/model_service/test.toml | 18 + .../invariant/configs/model_service.yml.tmpl | 9 + .../invariant/continue_293/out.test.toml | 3 +- .../invariant/delete_idempotent/out.test.toml | 3 +- .../destroy_idempotent/out.test.toml | 3 +- acceptance/bundle/invariant/migrate/test.toml | 2 + .../bundle/invariant/no_drift/out.test.toml | 3 +- acceptance/bundle/invariant/test.toml | 5 + acceptance/bundle/refschema/out.fields.txt | 62 +++ .../model_services/basic/databricks.yml | 10 + .../model_services/basic/out.test.toml | 3 + .../resources/model_services/basic/output.txt | 92 ++++ .../resources/model_services/basic/script | 29 ++ .../resources/model_services/basic/test.toml | 3 + .../remote-delete/databricks.yml | 10 + .../remote-delete/out.test.toml | 3 + .../model_services/remote-delete/output.txt | 20 + .../model_services/remote-delete/script | 9 + .../model_services/remote-delete/test.toml | 5 + .../bundle/resources/model_services/test.toml | 3 + .../apply_bundle_permissions_test.go | 1 + .../resourcemutator/apply_target_mode_test.go | 3 + .../mutator/resourcemutator/run_as_test.go | 2 + bundle/config/resources.go | 3 + bundle/config/resources/model_service.go | 91 ++++ bundle/config/resources_test.go | 9 + bundle/direct/dresources/all.go | 1 + bundle/direct/dresources/all_test.go | 8 + .../direct/dresources/apitypes.generated.yml | 2 + bundle/direct/dresources/model_service.go | 118 +++++ .../direct/dresources/resources.generated.yml | 2 + bundle/direct/dresources/resources.yml | 28 ++ bundle/internal/schema/annotations.yml | 19 + .../validation/generated/enum_fields.go | 5 + .../validation/generated/required_fields.go | 14 + bundle/schema/jsonschema.json | 455 ++++++++++++++++++ libs/testserver/fake_workspace.go | 2 + libs/testserver/handlers.go | 18 + libs/testserver/model_services.go | 77 +++ 43 files changed, 1196 insertions(+), 4 deletions(-) create mode 100644 acceptance/bundle/deployment/bind/model_service/databricks.yml create mode 100644 acceptance/bundle/deployment/bind/model_service/out.test.toml create mode 100644 acceptance/bundle/deployment/bind/model_service/output.txt create mode 100644 acceptance/bundle/deployment/bind/model_service/script create mode 100644 acceptance/bundle/deployment/bind/model_service/test.toml create mode 100644 acceptance/bundle/invariant/configs/model_service.yml.tmpl create mode 100644 acceptance/bundle/resources/model_services/basic/databricks.yml create mode 100644 acceptance/bundle/resources/model_services/basic/out.test.toml create mode 100644 acceptance/bundle/resources/model_services/basic/output.txt create mode 100644 acceptance/bundle/resources/model_services/basic/script create mode 100644 acceptance/bundle/resources/model_services/basic/test.toml create mode 100644 acceptance/bundle/resources/model_services/remote-delete/databricks.yml create mode 100644 acceptance/bundle/resources/model_services/remote-delete/out.test.toml create mode 100644 acceptance/bundle/resources/model_services/remote-delete/output.txt create mode 100644 acceptance/bundle/resources/model_services/remote-delete/script create mode 100644 acceptance/bundle/resources/model_services/remote-delete/test.toml create mode 100644 acceptance/bundle/resources/model_services/test.toml create mode 100644 bundle/config/resources/model_service.go create mode 100644 bundle/direct/dresources/model_service.go create mode 100644 libs/testserver/model_services.go diff --git a/acceptance/bundle/deployment/bind/model_service/databricks.yml b/acceptance/bundle/deployment/bind/model_service/databricks.yml new file mode 100644 index 00000000000..a49f478d480 --- /dev/null +++ b/acceptance/bundle/deployment/bind/model_service/databricks.yml @@ -0,0 +1,10 @@ +bundle: + name: test-bundle + +resources: + model_services: + ms1: + parent: schemas/main.myschema + model_service_id: mysvc + comment: bound service + diff --git a/acceptance/bundle/deployment/bind/model_service/out.test.toml b/acceptance/bundle/deployment/bind/model_service/out.test.toml new file mode 100644 index 00000000000..0938e678987 --- /dev/null +++ b/acceptance/bundle/deployment/bind/model_service/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/deployment/bind/model_service/output.txt b/acceptance/bundle/deployment/bind/model_service/output.txt new file mode 100644 index 00000000000..64df67b3d1f --- /dev/null +++ b/acceptance/bundle/deployment/bind/model_service/output.txt @@ -0,0 +1,30 @@ + +>>> [CLI] bundle deployment bind ms1 main.myschema.mysvc --auto-approve +Successfully bound model_service with an id 'main.myschema.mysvc' +Run 'bundle deploy' to deploy changes to your workspace + +>>> [CLI] bundle summary +Name: test-bundle +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default +Resources: + Model services: + ms1: + Name: mysvc + URL: (not deployed) + +>>> [CLI] bundle deployment unbind ms1 + +>>> [CLI] bundle summary +Name: test-bundle +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default +Resources: + Model services: + ms1: + Name: mysvc + URL: (not deployed) diff --git a/acceptance/bundle/deployment/bind/model_service/script b/acceptance/bundle/deployment/bind/model_service/script new file mode 100644 index 00000000000..e886f5dcd85 --- /dev/null +++ b/acceptance/bundle/deployment/bind/model_service/script @@ -0,0 +1,5 @@ +trace $CLI bundle deployment bind ms1 main.myschema.mysvc --auto-approve +trace $CLI bundle summary + +trace $CLI bundle deployment unbind ms1 +trace $CLI bundle summary diff --git a/acceptance/bundle/deployment/bind/model_service/test.toml b/acceptance/bundle/deployment/bind/model_service/test.toml new file mode 100644 index 00000000000..067e1b6e272 --- /dev/null +++ b/acceptance/bundle/deployment/bind/model_service/test.toml @@ -0,0 +1,18 @@ +# AI Gateway securables are direct-engine only. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] + +Cloud = false + +Ignore = [ + ".databricks", +] + +# The bind flow issues a GET to confirm the remote resource exists before binding. +[[Server]] +Pattern = "GET /api/2.1/unity-catalog/model-services/{name}" +Response.Body = ''' +{ + "name": "model-services/main.myschema.mysvc", + "comment": "bound service" +} +''' \ No newline at end of file diff --git a/acceptance/bundle/invariant/configs/model_service.yml.tmpl b/acceptance/bundle/invariant/configs/model_service.yml.tmpl new file mode 100644 index 00000000000..e4ea6c39dc8 --- /dev/null +++ b/acceptance/bundle/invariant/configs/model_service.yml.tmpl @@ -0,0 +1,9 @@ +bundle: + name: test-bundle-$UNIQUE_NAME + +resources: + model_services: + foo: + parent: schemas/main.default + model_service_id: test-model-service-$UNIQUE_NAME + comment: test model service diff --git a/acceptance/bundle/invariant/continue_293/out.test.toml b/acceptance/bundle/invariant/continue_293/out.test.toml index 20fe46d85de..e23caeac873 100644 --- a/acceptance/bundle/invariant/continue_293/out.test.toml +++ b/acceptance/bundle/invariant/continue_293/out.test.toml @@ -45,5 +45,6 @@ EnvMatrix.INPUT_CONFIG = [ "synced_database_table.yml.tmpl", "volume.yml.tmpl", "volume_external.yml.tmpl", - "volume_uppercase_name.yml.tmpl" + "volume_uppercase_name.yml.tmpl", + "model_service.yml.tmpl" ] diff --git a/acceptance/bundle/invariant/delete_idempotent/out.test.toml b/acceptance/bundle/invariant/delete_idempotent/out.test.toml index 59018deb1ce..2554ae2ce3f 100644 --- a/acceptance/bundle/invariant/delete_idempotent/out.test.toml +++ b/acceptance/bundle/invariant/delete_idempotent/out.test.toml @@ -60,6 +60,7 @@ EnvMatrix.INPUT_CONFIG = [ "volume.yml.tmpl", "volume_external.yml.tmpl", "volume_path_job_ref.yml.tmpl", - "volume_uppercase_name.yml.tmpl" + "volume_uppercase_name.yml.tmpl", + "model_service.yml.tmpl" ] EnvMatrix.READPLAN = ["", "1"] diff --git a/acceptance/bundle/invariant/destroy_idempotent/out.test.toml b/acceptance/bundle/invariant/destroy_idempotent/out.test.toml index 59018deb1ce..2554ae2ce3f 100644 --- a/acceptance/bundle/invariant/destroy_idempotent/out.test.toml +++ b/acceptance/bundle/invariant/destroy_idempotent/out.test.toml @@ -60,6 +60,7 @@ EnvMatrix.INPUT_CONFIG = [ "volume.yml.tmpl", "volume_external.yml.tmpl", "volume_path_job_ref.yml.tmpl", - "volume_uppercase_name.yml.tmpl" + "volume_uppercase_name.yml.tmpl", + "model_service.yml.tmpl" ] EnvMatrix.READPLAN = ["", "1"] diff --git a/acceptance/bundle/invariant/migrate/test.toml b/acceptance/bundle/invariant/migrate/test.toml index 2924a3dec1c..0c94059c56d 100644 --- a/acceptance/bundle/invariant/migrate/test.toml +++ b/acceptance/bundle/invariant/migrate/test.toml @@ -21,6 +21,8 @@ EnvMatrixExclude.no_genie_space = ["INPUT_CONFIG=genie_space.yml.tmpl"] EnvMatrixExclude.no_instance_pool = ["INPUT_CONFIG=instance_pool.yml.tmpl"] # Cluster policies are direct-only; the terraform deploy that seeds the migration fails for them. EnvMatrixExclude.no_cluster_policy = ["INPUT_CONFIG=cluster_policy.yml.tmpl"] +# AI Gateway securables are direct-only; the terraform deploy that seeds the migration fails for them. +EnvMatrixExclude.no_model_service = ["INPUT_CONFIG=model_service.yml.tmpl"] # Cross-resource permission references (e.g. ${resources.jobs.job_b.permissions[0].level}) # don't work in terraform mode: the terraform interpolator converts the path to diff --git a/acceptance/bundle/invariant/no_drift/out.test.toml b/acceptance/bundle/invariant/no_drift/out.test.toml index 8439713320e..5ea98dcf5a2 100644 --- a/acceptance/bundle/invariant/no_drift/out.test.toml +++ b/acceptance/bundle/invariant/no_drift/out.test.toml @@ -60,6 +60,7 @@ EnvMatrix.INPUT_CONFIG = [ "volume.yml.tmpl", "volume_external.yml.tmpl", "volume_path_job_ref.yml.tmpl", - "volume_uppercase_name.yml.tmpl" + "volume_uppercase_name.yml.tmpl", + "model_service.yml.tmpl" ] EnvMatrix.READPLAN = ["", "1"] diff --git a/acceptance/bundle/invariant/test.toml b/acceptance/bundle/invariant/test.toml index 37bbb2c5103..780f79774b0 100644 --- a/acceptance/bundle/invariant/test.toml +++ b/acceptance/bundle/invariant/test.toml @@ -78,6 +78,7 @@ EnvMatrix.INPUT_CONFIG = [ "volume_external.yml.tmpl", "volume_path_job_ref.yml.tmpl", "volume_uppercase_name.yml.tmpl", + "model_service.yml.tmpl", ] EnvMatrixExclude.no_alert_on_cloud = ["CONFIG_Cloud=true", "INPUT_CONFIG=alert.yml.tmpl"] @@ -108,6 +109,10 @@ EnvMatrixExclude.no_external_location_on_cloud = ["CONFIG_Cloud=true", "INPUT_CO EnvMatrixExclude.no_external_volume_on_cloud = ["CONFIG_Cloud=true", "INPUT_CONFIG=volume_external.yml.tmpl"] # Telemetry requires a real model, which cloud invariant tests do not provision. EnvMatrixExclude.no_model_serving_endpoint_telemetry_on_cloud = ["CONFIG_Cloud=true", "INPUT_CONFIG=model_serving_endpoint_telemetry.yml.tmpl"] +# AI Gateway securables (direct-only) need a real routing target / provider config / +# UC connection to create on cloud, which the invariant tests don't provision; the +# local mock server accepts them, so these run locally only. +EnvMatrixExclude.no_model_service_on_cloud = ["CONFIG_Cloud=true", "INPUT_CONFIG=model_service.yml.tmpl"] # Fake SQL endpoint for local tests [[Server]] diff --git a/acceptance/bundle/refschema/out.fields.txt b/acceptance/bundle/refschema/out.fields.txt index de252dc4162..3a9ca26c49f 100644 --- a/acceptance/bundle/refschema/out.fields.txt +++ b/acceptance/bundle/refschema/out.fields.txt @@ -2043,6 +2043,68 @@ resources.jobs.*.permissions[*].group_name string ALL resources.jobs.*.permissions[*].level iam.PermissionLevel ALL resources.jobs.*.permissions[*].service_principal_name string ALL resources.jobs.*.permissions[*].user_name string ALL +resources.model_services.*.comment string ALL +resources.model_services.*.config *catalog.ModelServiceConfig ALL +resources.model_services.*.config.inference_table *catalog.InferenceTableConfig ALL +resources.model_services.*.config.inference_table.disabled bool ALL +resources.model_services.*.config.inference_table.is_deleted bool ALL +resources.model_services.*.config.inference_table.parent string ALL +resources.model_services.*.config.inference_table.table string ALL +resources.model_services.*.config.inference_table.table_name_prefix string ALL +resources.model_services.*.config.rate_limits []catalog.RateLimit ALL +resources.model_services.*.config.rate_limits[*] catalog.RateLimit ALL +resources.model_services.*.config.rate_limits[*].key catalog.RateLimitRateLimitKey ALL +resources.model_services.*.config.rate_limits[*].principal string ALL +resources.model_services.*.config.rate_limits[*].renewal_period catalog.RateLimitRateLimitRenewalPeriod ALL +resources.model_services.*.config.rate_limits[*].request_tag_key string ALL +resources.model_services.*.config.rate_limits[*].request_tag_value string ALL +resources.model_services.*.config.rate_limits[*].requests int64 ALL +resources.model_services.*.config.rate_limits[*].tokens int64 ALL +resources.model_services.*.config.routing *catalog.ModelServiceConfigRoutingConfig ALL +resources.model_services.*.config.routing.destinations []catalog.ModelServiceConfigDestinationConfig ALL +resources.model_services.*.config.routing.destinations[*] catalog.ModelServiceConfigDestinationConfig ALL +resources.model_services.*.config.routing.destinations[*].destination_type catalog.ModelServiceConfigDestinationConfigDestinationType ALL +resources.model_services.*.config.routing.destinations[*].external_model_config *catalog.ModelServiceConfigExternalModelConfig ALL +resources.model_services.*.config.routing.destinations[*].external_model_config.model_provider_service string ALL +resources.model_services.*.config.routing.destinations[*].external_model_config.target catalog.ModelProviderServiceConfigModelTargetConfig ALL +resources.model_services.*.config.routing.destinations[*].external_model_config.target.model string ALL +resources.model_services.*.config.routing.destinations[*].external_model_config.target.native_api_types []string ALL +resources.model_services.*.config.routing.destinations[*].external_model_config.target.native_api_types[*] string ALL +resources.model_services.*.config.routing.destinations[*].is_deleted bool ALL +resources.model_services.*.config.routing.destinations[*].name string ALL +resources.model_services.*.config.routing.destinations[*].pay_per_token_config *catalog.ModelServiceConfigPayPerTokenConfig ALL +resources.model_services.*.config.routing.destinations[*].pay_per_token_config.model string ALL +resources.model_services.*.config.routing.destinations[*].provisioned_throughput_config *catalog.ModelServiceConfigProvisionedThroughputConfig ALL +resources.model_services.*.config.routing.destinations[*].provisioned_throughput_config.model string ALL +resources.model_services.*.config.routing.destinations[*].provisioned_throughput_config.model_serving_endpoint string ALL +resources.model_services.*.config.routing.destinations[*].traffic_percentage int ALL +resources.model_services.*.config.routing.fallback *catalog.ModelServiceConfigFallbackConfig ALL +resources.model_services.*.config.routing.fallback.destinations []catalog.ModelServiceConfigDestinationConfig ALL +resources.model_services.*.config.routing.fallback.destinations[*] catalog.ModelServiceConfigDestinationConfig ALL +resources.model_services.*.config.routing.fallback.destinations[*].destination_type catalog.ModelServiceConfigDestinationConfigDestinationType ALL +resources.model_services.*.config.routing.fallback.destinations[*].external_model_config *catalog.ModelServiceConfigExternalModelConfig ALL +resources.model_services.*.config.routing.fallback.destinations[*].external_model_config.model_provider_service string ALL +resources.model_services.*.config.routing.fallback.destinations[*].external_model_config.target catalog.ModelProviderServiceConfigModelTargetConfig ALL +resources.model_services.*.config.routing.fallback.destinations[*].external_model_config.target.model string ALL +resources.model_services.*.config.routing.fallback.destinations[*].external_model_config.target.native_api_types []string ALL +resources.model_services.*.config.routing.fallback.destinations[*].external_model_config.target.native_api_types[*] string ALL +resources.model_services.*.config.routing.fallback.destinations[*].is_deleted bool ALL +resources.model_services.*.config.routing.fallback.destinations[*].name string ALL +resources.model_services.*.config.routing.fallback.destinations[*].pay_per_token_config *catalog.ModelServiceConfigPayPerTokenConfig ALL +resources.model_services.*.config.routing.fallback.destinations[*].pay_per_token_config.model string ALL +resources.model_services.*.config.routing.fallback.destinations[*].provisioned_throughput_config *catalog.ModelServiceConfigProvisionedThroughputConfig ALL +resources.model_services.*.config.routing.fallback.destinations[*].provisioned_throughput_config.model string ALL +resources.model_services.*.config.routing.fallback.destinations[*].provisioned_throughput_config.model_serving_endpoint string ALL +resources.model_services.*.config.routing.fallback.destinations[*].traffic_percentage int ALL +resources.model_services.*.config.routing.first_token_timeout *duration.Duration ALL +resources.model_services.*.config.routing.traffic_splitting *catalog.ModelServiceConfigRoutingConfigTrafficSplitting ALL +resources.model_services.*.id string INPUT +resources.model_services.*.lifecycle resources.Lifecycle INPUT +resources.model_services.*.lifecycle.prevent_destroy bool INPUT +resources.model_services.*.model_service_id string ALL +resources.model_services.*.modified_status string INPUT +resources.model_services.*.parent string ALL +resources.model_services.*.url string INPUT resources.model_serving_endpoints.*.ai_gateway *serving.AiGatewayConfig ALL resources.model_serving_endpoints.*.ai_gateway.fallback_config *serving.FallbackConfig ALL resources.model_serving_endpoints.*.ai_gateway.fallback_config.enabled bool ALL diff --git a/acceptance/bundle/resources/model_services/basic/databricks.yml b/acceptance/bundle/resources/model_services/basic/databricks.yml new file mode 100644 index 00000000000..36d7b8f54b0 --- /dev/null +++ b/acceptance/bundle/resources/model_services/basic/databricks.yml @@ -0,0 +1,10 @@ +bundle: + name: test-bundle + +resources: + model_services: + ms1: + parent: schemas/main.myschema + model_service_id: myservice + comment: COMMENT1 + diff --git a/acceptance/bundle/resources/model_services/basic/out.test.toml b/acceptance/bundle/resources/model_services/basic/out.test.toml new file mode 100644 index 00000000000..59b56a2037c --- /dev/null +++ b/acceptance/bundle/resources/model_services/basic/out.test.toml @@ -0,0 +1,3 @@ +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] +EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/resources/model_services/basic/output.txt b/acceptance/bundle/resources/model_services/basic/output.txt new file mode 100644 index 00000000000..718c2015751 --- /dev/null +++ b/acceptance/bundle/resources/model_services/basic/output.txt @@ -0,0 +1,92 @@ + +=== Initial summary before deploy +>>> [CLI] bundle summary -o json +{ + "comment": "COMMENT1", + "model_service_id": "myservice", + "modified_status": "created", + "parent": "schemas/main.myschema" +} + +=== Verify it does not exist yet +>>> musterr [CLI] ai-gateway get-model-service model-services/main.myschema.myservice +Error: Resource catalog.ModelService not found: main.myschema.myservice + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files... +Created model_services.ms1 +Files: 5 uploaded, 0 deleted +Resources: 1 created, 0 changed, 0 deleted, 0 unchanged + +>>> print_requests.py //unity +{ + "method": "POST", + "path": "/api/2.1/unity-catalog/model-services", + "q": { + "model_service_id": "myservice", + "parent": "schemas/main.myschema" + }, + "body": { + "comment": "COMMENT1" + } +} + +=== Summary should show the id (url is not wired for these resources) +>>> [CLI] bundle summary -o json +{ + "id": "main.myschema.myservice", + "url": null +} + +=== Verify deployment +>>> [CLI] ai-gateway get-model-service model-services/main.myschema.myservice +{ + "name": "model-services/main.myschema.myservice", + "comment": "COMMENT1" +} + +=== Update comment (should update in place, not recreate) +>>> update_file.py databricks.yml COMMENT1 COMMENT2 + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files... +Updated model_services.ms1 +Files: 3 uploaded, 0 deleted +Resources: 0 created, 1 changed, 0 deleted, 0 unchanged + +>>> print_requests.py //unity +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/model-services/main.myschema.myservice", + "q": { + "update_mask": "comment,config" + }, + "body": { + "comment": "COMMENT2" + } +} + +>>> [CLI] ai-gateway get-model-service model-services/main.myschema.myservice +"COMMENT2" + +=== Change an immutable field (should plan a recreate) +>>> update_file.py databricks.yml myservice myservice-renamed + +>>> [CLI] bundle plan +recreate model_services.ms1 + +Plan: 1 to add, 0 to change, 1 to delete, 0 unchanged + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.model_services.ms1 + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default + +Destroy: 1 deleted + +>>> print_requests.py //unity +{ + "method": "DELETE", + "path": "/api/2.1/unity-catalog/model-services/main.myschema.myservice" +} diff --git a/acceptance/bundle/resources/model_services/basic/script b/acceptance/bundle/resources/model_services/basic/script new file mode 100644 index 00000000000..ab07b28ce0a --- /dev/null +++ b/acceptance/bundle/resources/model_services/basic/script @@ -0,0 +1,29 @@ +title "Initial summary before deploy" +trace $CLI bundle summary -o json | jq .resources.model_services.ms1 + +title "Verify it does not exist yet" +trace musterr $CLI ai-gateway get-model-service model-services/main.myschema.myservice + +trace $CLI bundle deploy +trace print_requests.py //unity + +title "Summary should show the id (url is not wired for these resources)" +trace $CLI bundle summary -o json | jq ".resources.model_services.ms1 | {id, url}" + +title "Verify deployment" +trace $CLI ai-gateway get-model-service model-services/main.myschema.myservice | jq '{name, comment}' + +title "Update comment (should update in place, not recreate)" +trace update_file.py databricks.yml COMMENT1 COMMENT2 +trace $CLI bundle deploy +trace print_requests.py //unity +trace $CLI ai-gateway get-model-service model-services/main.myschema.myservice | jq .comment + +title "Change an immutable field (should plan a recreate)" +trace update_file.py databricks.yml myservice myservice-renamed +trace $CLI bundle plan + +trace $CLI bundle destroy --auto-approve +trace print_requests.py //unity + +rm -f out.requests.txt diff --git a/acceptance/bundle/resources/model_services/basic/test.toml b/acceptance/bundle/resources/model_services/basic/test.toml new file mode 100644 index 00000000000..bd4b66fe75e --- /dev/null +++ b/acceptance/bundle/resources/model_services/basic/test.toml @@ -0,0 +1,3 @@ +Ignore = [ + ".databricks", +] diff --git a/acceptance/bundle/resources/model_services/remote-delete/databricks.yml b/acceptance/bundle/resources/model_services/remote-delete/databricks.yml new file mode 100644 index 00000000000..36d7b8f54b0 --- /dev/null +++ b/acceptance/bundle/resources/model_services/remote-delete/databricks.yml @@ -0,0 +1,10 @@ +bundle: + name: test-bundle + +resources: + model_services: + ms1: + parent: schemas/main.myschema + model_service_id: myservice + comment: COMMENT1 + diff --git a/acceptance/bundle/resources/model_services/remote-delete/out.test.toml b/acceptance/bundle/resources/model_services/remote-delete/out.test.toml new file mode 100644 index 00000000000..59b56a2037c --- /dev/null +++ b/acceptance/bundle/resources/model_services/remote-delete/out.test.toml @@ -0,0 +1,3 @@ +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] +EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/resources/model_services/remote-delete/output.txt b/acceptance/bundle/resources/model_services/remote-delete/output.txt new file mode 100644 index 00000000000..c7f74e3b69f --- /dev/null +++ b/acceptance/bundle/resources/model_services/remote-delete/output.txt @@ -0,0 +1,20 @@ + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files... +Created model_services.ms1 +Files: 4 uploaded, 0 deleted +Resources: 1 created, 0 changed, 0 deleted, 0 unchanged + +=== Delete the resource out of band +>>> [CLI] ai-gateway delete-model-service model-services/main.myschema.myservice + +=== Plan should detect the resource is gone and re-create it +>>> [CLI] bundle plan +create model_services.ms1 + +Plan: 1 to add, 0 to change, 0 to delete, 0 unchanged + +>>> [CLI] bundle destroy --auto-approve +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default + +Destroy: 0 deleted diff --git a/acceptance/bundle/resources/model_services/remote-delete/script b/acceptance/bundle/resources/model_services/remote-delete/script new file mode 100644 index 00000000000..ad9170ef687 --- /dev/null +++ b/acceptance/bundle/resources/model_services/remote-delete/script @@ -0,0 +1,9 @@ +trace $CLI bundle deploy + +title "Delete the resource out of band" +trace $CLI ai-gateway delete-model-service model-services/main.myschema.myservice + +title "Plan should detect the resource is gone and re-create it" +trace $CLI bundle plan + +trace $CLI bundle destroy --auto-approve diff --git a/acceptance/bundle/resources/model_services/remote-delete/test.toml b/acceptance/bundle/resources/model_services/remote-delete/test.toml new file mode 100644 index 00000000000..daa475f7cee --- /dev/null +++ b/acceptance/bundle/resources/model_services/remote-delete/test.toml @@ -0,0 +1,5 @@ +RecordRequests = false + +Ignore = [ + ".databricks", +] diff --git a/acceptance/bundle/resources/model_services/test.toml b/acceptance/bundle/resources/model_services/test.toml new file mode 100644 index 00000000000..b5361a56ad7 --- /dev/null +++ b/acceptance/bundle/resources/model_services/test.toml @@ -0,0 +1,3 @@ +# AI Gateway securables are only deployable via the direct deployment engine +# (there is no Terraform provider path for them in bundles). +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go b/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go index 99ac6759ee8..e7bf0af4151 100644 --- a/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go +++ b/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go @@ -25,6 +25,7 @@ var unsupportedResources = []string{ "schemas", "quality_monitors", "registered_models", + "model_services", "database_catalogs", "synced_database_tables", "postgres_branches", diff --git a/bundle/config/mutator/resourcemutator/apply_target_mode_test.go b/bundle/config/mutator/resourcemutator/apply_target_mode_test.go index 35eed05cc53..e4325173d46 100644 --- a/bundle/config/mutator/resourcemutator/apply_target_mode_test.go +++ b/bundle/config/mutator/resourcemutator/apply_target_mode_test.go @@ -108,6 +108,9 @@ func mockBundle(mode config.Mode) *bundle.Bundle { ModelServingEndpoints: map[string]*resources.ModelServingEndpoint{ "servingendpoint1": {CreateServingEndpoint: serving.CreateServingEndpoint{Name: "servingendpoint1"}}, }, + ModelServices: map[string]*resources.ModelService{ + "modelservice1": {ModelServiceConfig: resources.ModelServiceConfig{Parent: "schemas/main.default", ModelServiceId: "modelservice1"}}, + }, RegisteredModels: map[string]*resources.RegisteredModel{ "registeredmodel1": {CreateRegisteredModelRequest: catalog.CreateRegisteredModelRequest{Name: "registeredmodel1"}}, }, diff --git a/bundle/config/mutator/resourcemutator/run_as_test.go b/bundle/config/mutator/resourcemutator/run_as_test.go index be9abfe6836..68c0d1c6b48 100644 --- a/bundle/config/mutator/resourcemutator/run_as_test.go +++ b/bundle/config/mutator/resourcemutator/run_as_test.go @@ -47,6 +47,7 @@ func allResourceTypes(t *testing.T) []string { "instance_pools", "job_runs", "jobs", + "model_services", "model_serving_endpoints", "models", "pipelines", @@ -185,6 +186,7 @@ var allowList = []string{ "jobs", "pipelines", "models", + "model_services", "postgres_branches", "postgres_catalogs", "postgres_databases", diff --git a/bundle/config/resources.go b/bundle/config/resources.go index 633332a78c0..b89cb5a4477 100644 --- a/bundle/config/resources.go +++ b/bundle/config/resources.go @@ -18,6 +18,7 @@ type Resources struct { Models map[string]*resources.MlflowModel `json:"models,omitempty"` Experiments map[string]*resources.MlflowExperiment `json:"experiments,omitempty"` ModelServingEndpoints map[string]*resources.ModelServingEndpoint `json:"model_serving_endpoints,omitempty"` + ModelServices map[string]*resources.ModelService `json:"model_services,omitempty"` RegisteredModels map[string]*resources.RegisteredModel `json:"registered_models,omitempty"` QualityMonitors map[string]*resources.QualityMonitor `json:"quality_monitors,omitempty"` Catalogs map[string]*resources.Catalog `json:"catalogs,omitempty"` @@ -105,6 +106,7 @@ func (r *Resources) AllResources() []ResourceGroup { collectResourceMap(descriptions["models"], r.Models), collectResourceMap(descriptions["experiments"], r.Experiments), collectResourceMap(descriptions["model_serving_endpoints"], r.ModelServingEndpoints), + collectResourceMap(descriptions["model_services"], r.ModelServices), collectResourceMap(descriptions["registered_models"], r.RegisteredModels), collectResourceMap(descriptions["quality_monitors"], r.QualityMonitors), collectResourceMap(descriptions["catalogs"], r.Catalogs), @@ -171,6 +173,7 @@ func SupportedResources() map[string]resources.ResourceDescription { "experiments": (&resources.MlflowExperiment{}).ResourceDescription(), "instance_pools": (&resources.InstancePool{}).ResourceDescription(), "model_serving_endpoints": (&resources.ModelServingEndpoint{}).ResourceDescription(), + "model_services": (&resources.ModelService{}).ResourceDescription(), "registered_models": (&resources.RegisteredModel{}).ResourceDescription(), "quality_monitors": (&resources.QualityMonitor{}).ResourceDescription(), "catalogs": (&resources.Catalog{}).ResourceDescription(), diff --git a/bundle/config/resources/model_service.go b/bundle/config/resources/model_service.go new file mode 100644 index 00000000000..9fdfb0fd47b --- /dev/null +++ b/bundle/config/resources/model_service.go @@ -0,0 +1,91 @@ +package resources + +import ( + "context" + "net/url" + + "github.com/databricks/cli/libs/log" + "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/apierr" + "github.com/databricks/databricks-sdk-go/marshal" + "github.com/databricks/databricks-sdk-go/service/catalog" +) + +// ModelServiceConfig is the bundle-authored state for an AI Gateway model +// service. +// +// The SDK models the create inputs `parent` and `model_service_id` as URL +// parameters (`json:"-"`) that sit outside the ModelService body, and derives +// the resource `name` (`model-services/{catalog}.{schema}.{model_service}`) +// server-side. We therefore cannot embed catalog.CreateModelServiceRequest the +// way volume embeds catalog.CreateVolumeRequestContent: its identity fields +// would be invisible to the bundle schema. Instead we expose a flat struct with +// the immutable identity (parent + model_service_id) plus the mutable body. +// +// Owner is intentionally not exposed yet (mirrors volume, which does not manage +// owner): the API returns effective_owner rather than owner on read, so round +// tripping it needs extra care. See the direct engine resource for the CRUD. +type ModelServiceConfig struct { + // Parent schema, format `schemas/{catalog}.{schema}`. Immutable: the server + // derives `name` from parent + model_service_id, so changing it recreates + // the resource. + Parent string `json:"parent"` + // Leaf id of the model service, e.g. "my_model_service". Immutable. + ModelServiceId string `json:"model_service_id"` + // User-provided description. + Comment string `json:"comment,omitempty"` + // Operational configuration: destinations, routing, rate limits, inference + // table. + Config *catalog.ModelServiceConfig `json:"config,omitempty"` + + ForceSendFields []string `json:"-" url:"-"` +} + +func (c *ModelServiceConfig) UnmarshalJSON(b []byte) error { + return marshal.Unmarshal(b, c) +} + +func (c ModelServiceConfig) MarshalJSON() ([]byte, error) { + return marshal.Marshal(c) +} + +type ModelService struct { + BaseResource + ModelServiceConfig +} + +func (m *ModelService) Exists(ctx context.Context, w *databricks.WorkspaceClient, id string) (bool, error) { + // The engine tracks the id as the bare {catalog}.{schema}.{model_service}; + // the API addresses the resource by its full name. + _, err := w.AiGateway.GetModelService(ctx, catalog.GetModelServiceRequest{Name: "model-services/" + id}) + if err != nil { + log.Debugf(ctx, "model service %s does not exist", id) + if apierr.IsMissing(err) { + return false, nil + } + return false, err + } + return true, nil +} + +func (*ModelService) ResourceDescription() ResourceDescription { + return ResourceDescription{ + SingularName: "model_service", + PluralName: "model_services", + SingularTitle: "Model service", + PluralTitle: "Model services", + } +} + +func (m *ModelService) InitializeURL(_ url.URL) { + // AI Gateway model services do not have a Catalog Explorer URL wired up + // here yet; leave URL unset until the UI route is confirmed. +} + +func (m *ModelService) GetName() string { + return m.ModelServiceId +} + +func (m *ModelService) GetURL() string { + return m.URL +} diff --git a/bundle/config/resources_test.go b/bundle/config/resources_test.go index 7e56f47a64a..faaf313f153 100644 --- a/bundle/config/resources_test.go +++ b/bundle/config/resources_test.go @@ -125,6 +125,9 @@ func TestBundleResourcePluralNamesResolveInWorkspaceURLs(t *testing.T) { // Resources that intentionally have no workspace URL. noURL := map[string]bool{ "external_locations": true, + // AI Gateway securables are addressed by a server-derived resource name + // and have no single-ID Catalog Explorer URL wired up yet. + "model_services": true, // A job run does have a workspace URL, but it's addressed by two IDs // (job + run) so it can't be expressed as a single-ID pattern here; it's // built in JobRun.InitializeURL via workspaceurls.JobRunURL instead. @@ -238,6 +241,11 @@ func TestResourcesBindSupport(t *testing.T) { CreateServingEndpoint: serving.CreateServingEndpoint{}, }, }, + ModelServices: map[string]*resources.ModelService{ + "my_model_service": { + ModelServiceConfig: resources.ModelServiceConfig{}, + }, + }, SecretScopes: map[string]*resources.SecretScope{ "my_secret_scope": { Name: "0", @@ -377,6 +385,7 @@ func TestResourcesBindSupport(t *testing.T) { m.GetMockAlertsV2API().EXPECT().GetAlertById(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockQualityMonitorsAPI().EXPECT().Get(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockServingEndpointsAPI().EXPECT().Get(mock.Anything, mock.Anything).Return(nil, nil) + m.GetMockAiGatewayAPI().EXPECT().GetModelService(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockSecretsAPI().EXPECT().ListScopesAll(mock.Anything).Return([]workspace.SecretScope{ {Name: "0"}, }, nil) diff --git a/bundle/direct/dresources/all.go b/bundle/direct/dresources/all.go index 391fb0684d2..573cd244762 100644 --- a/bundle/direct/dresources/all.go +++ b/bundle/direct/dresources/all.go @@ -35,6 +35,7 @@ var SupportedResources = map[string]any{ "genie_spaces": (*ResourceGenieSpace)(nil), "secret_scopes": (*ResourceSecretScope)(nil), "model_serving_endpoints": (*ResourceModelServingEndpoint)(nil), + "model_services": (*ResourceModelService)(nil), "quality_monitors": (*ResourceQualityMonitor)(nil), "vector_search_endpoints": (*ResourceVectorSearchEndpoint)(nil), "vector_search_indexes": (*ResourceVectorSearchIndex)(nil), diff --git a/bundle/direct/dresources/all_test.go b/bundle/direct/dresources/all_test.go index 36eaa3e27df..18c03b30898 100644 --- a/bundle/direct/dresources/all_test.go +++ b/bundle/direct/dresources/all_test.go @@ -95,6 +95,14 @@ var testConfig map[string]any = map[string]any{ }, }, + "model_services": &resources.ModelService{ + ModelServiceConfig: resources.ModelServiceConfig{ + Parent: "schemas/main.default", + ModelServiceId: "my_model_service", + Comment: "Test model service", + }, + }, + "registered_models": &resources.RegisteredModel{ CreateRegisteredModelRequest: catalog.CreateRegisteredModelRequest{ Name: "my_registered_model", diff --git a/bundle/direct/dresources/apitypes.generated.yml b/bundle/direct/dresources/apitypes.generated.yml index ec2e3c2519c..18210a97dbb 100644 --- a/bundle/direct/dresources/apitypes.generated.yml +++ b/bundle/direct/dresources/apitypes.generated.yml @@ -28,6 +28,8 @@ job_runs: jobs.RunNow jobs: jobs.JobSettings +model_services: catalog.CreateModelServiceRequest + model_serving_endpoints: serving.CreateServingEndpoint models: ml.CreateModelRequest diff --git a/bundle/direct/dresources/model_service.go b/bundle/direct/dresources/model_service.go new file mode 100644 index 00000000000..295270e990e --- /dev/null +++ b/bundle/direct/dresources/model_service.go @@ -0,0 +1,118 @@ +package dresources + +import ( + "context" + "fmt" + "strings" + + "github.com/databricks/cli/bundle/config/resources" + "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/common/types/fieldmask" + "github.com/databricks/databricks-sdk-go/service/catalog" +) + +// AI Gateway model service. +// API: https://docs.databricks.com/api/workspace/aigateway +// Terraform: databricks_ai_gateway_model_service +// +// The remote type returned by DoRead is the same bundle-local +// resources.ModelServiceConfig used for state, so RemapState is not needed. +// DoRead reconstructs the create-time identity (parent + model_service_id) from +// the server-derived resource name so those fields participate in normal drift +// detection rather than being suppressed as missing-in-remote. +// modelServiceNamePrefix is the fixed prefix of the resource name +// (model-services/{catalog}.{schema}.{model_service}). The engine tracks the id +// as the bare {catalog}.{schema}.{model_service} portion and this prefix is +// re-added when addressing the resource through the SDK. +const modelServiceNamePrefix = "model-services/" + +type ResourceModelService struct { + client *databricks.WorkspaceClient +} + +func (*ResourceModelService) New(client *databricks.WorkspaceClient) *ResourceModelService { + return &ResourceModelService{client: client} +} + +func (*ResourceModelService) PrepareState(input *resources.ModelService) *resources.ModelServiceConfig { + return &input.ModelServiceConfig +} + +// modelServiceIdentityFromName reconstructs the create-time parent and leaf id +// from the server-derived resource name +// `model-services/{catalog}.{schema}.{model_service}`. +func modelServiceIdentityFromName(name string) (parent, modelServiceId string, err error) { + rest, ok := strings.CutPrefix(name, "model-services/") + if !ok { + return "", "", fmt.Errorf("unexpected model service name %q (want model-services/{catalog}.{schema}.{model_service})", name) + } + parts := strings.Split(rest, ".") + if len(parts) != 3 { + return "", "", fmt.Errorf("unexpected model service name %q (want three dot-separated components)", name) + } + return "schemas/" + parts[0] + "." + parts[1], parts[2], nil +} + +func responseToModelServiceConfig(ms *catalog.ModelService) (*resources.ModelServiceConfig, error) { + parent, id, err := modelServiceIdentityFromName(ms.Name) + if err != nil { + return nil, err + } + return &resources.ModelServiceConfig{ + Parent: parent, + ModelServiceId: id, + Comment: ms.Comment, + Config: ms.Config, + }, nil +} + +func (r *ResourceModelService) DoRead(ctx context.Context, id string) (*resources.ModelServiceConfig, error) { + ms, err := r.client.AiGateway.GetModelService(ctx, catalog.GetModelServiceRequest{Name: modelServiceNamePrefix + id}) + if err != nil { + return nil, err + } + return responseToModelServiceConfig(ms) +} + +func (r *ResourceModelService) DoCreate(ctx context.Context, config *resources.ModelServiceConfig) (string, *resources.ModelServiceConfig, error) { + resp, err := r.client.AiGateway.CreateModelService(ctx, catalog.CreateModelServiceRequest{ + Parent: config.Parent, + ModelServiceId: config.ModelServiceId, + ModelService: catalog.ModelService{ + Comment: config.Comment, + Config: config.Config, + }, + }) + if err != nil { + return "", nil, err + } + state, err := responseToModelServiceConfig(resp) + if err != nil { + return "", nil, err + } + return strings.TrimPrefix(resp.Name, modelServiceNamePrefix), state, nil +} + +// modelServiceUpdateMask lists the mutable fields sent on every update. name, +// parent and model_service_id are immutable (recreate_on_changes in +// resources.yml). The API rejects wildcard masks, so each path is explicit. +var modelServiceUpdateMask = []string{"comment", "config"} + +func (r *ResourceModelService) DoUpdate(ctx context.Context, id string, config *resources.ModelServiceConfig, _ *PlanEntry) (*resources.ModelServiceConfig, error) { + resp, err := r.client.AiGateway.UpdateModelService(ctx, catalog.UpdateModelServiceRequest{ + Name: modelServiceNamePrefix + id, + ModelService: catalog.ModelService{ + Comment: config.Comment, + Config: config.Config, + }, + UpdateMask: fieldmask.FieldMask{Paths: modelServiceUpdateMask}, + }) + if err != nil { + return nil, err + } + return responseToModelServiceConfig(resp) +} + +func (r *ResourceModelService) DoDelete(ctx context.Context, id string, _ *resources.ModelServiceConfig) error { + return r.client.AiGateway.DeleteModelService(ctx, catalog.DeleteModelServiceRequest{Name: modelServiceNamePrefix + id}) +} diff --git a/bundle/direct/dresources/resources.generated.yml b/bundle/direct/dresources/resources.generated.yml index d2dfda855b6..09c6d2ffadc 100644 --- a/bundle/direct/dresources/resources.generated.yml +++ b/bundle/direct/dresources/resources.generated.yml @@ -209,6 +209,8 @@ resources: # jobs: no api field behaviors + # model_services: no api field behaviors + model_serving_endpoints: ignore_remote_changes: diff --git a/bundle/direct/dresources/resources.yml b/bundle/direct/dresources/resources.yml index 044fbc4fddb..86adca21068 100644 --- a/bundle/direct/dresources/resources.yml +++ b/bundle/direct/dresources/resources.yml @@ -371,6 +371,34 @@ resources: # and the remote diverges, normal drift detection still applies. - field: config.traffic_config + model_services: + provided_id_fields: + # parent + model_service_id compose the server-derived resource name + # (model-services/{catalog}.{schema}.{model_service}), which is the ID the + # resource is fetched by. Both are immutable; a local change recreates. + # DoRead reconstructs them from the returned name, so a remote-only + # difference can only be normalization and is skipped. + - field: parent + reason: id_field + - field: model_service_id + reason: id_field + ignore_remote_changes: + # Server-resolved / tombstone fields returned on read, not user-authored. + # The generator maps model_services to the create-request type (config + # nested under model_service.*) so it can't emit these config.* paths; + # classify them by hand to avoid phantom drift. + - field: config.inference_table.table + reason: output_only + - field: config.inference_table.is_deleted + reason: output_only + # A routing target (model / MPS) that gets soft-deleted stays visible on + # read with is_deleted=true so the broken dependency is identifiable; the + # field is server-computed, not user-authored, so suppress it like above. + - field: config.routing.destinations[*].is_deleted + reason: output_only + - field: config.routing.fallback.destinations[*].is_deleted + reason: output_only + registered_models: ignore_remote_changes: # Output-only timestamp/user fields populated by the backend on read. diff --git a/bundle/internal/schema/annotations.yml b/bundle/internal/schema/annotations.yml index e0b712c8ceb..221a6a39510 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -1185,6 +1185,25 @@ resources: "table_update": "description": |- PLACEHOLDER + "model_services": + "description": |- + PLACEHOLDER + "$fields": + "comment": + "description": |- + PLACEHOLDER + "config": + "description": |- + PLACEHOLDER + "lifecycle": + "description": |- + PLACEHOLDER + "model_service_id": + "description": |- + PLACEHOLDER + "parent": + "description": |- + PLACEHOLDER "model_serving_endpoints": "description": |- The model serving endpoint definitions for the bundle, where each key is the name of the model serving endpoint. diff --git a/bundle/internal/validation/generated/enum_fields.go b/bundle/internal/validation/generated/enum_fields.go index 25d181f9733..9a8561e5ff0 100644 --- a/bundle/internal/validation/generated/enum_fields.go +++ b/bundle/internal/validation/generated/enum_fields.go @@ -156,6 +156,11 @@ var EnumFields = map[string][]string{ "resources.jobs.*.triggers[*].sql_condition.trigger_mode": {"QUERY_RETURNS_ROWS", "RESULT_VALUE_CHANGES"}, "resources.jobs.*.triggers[*].table_update.condition": {"ALL_UPDATED", "ANY_UPDATED"}, + "resources.model_services.*.config.rate_limits[*].key": {"RATE_LIMIT_KEY_REQUEST_TAG", "RATE_LIMIT_KEY_SERVICE", "RATE_LIMIT_KEY_SERVICE_PRINCIPAL", "RATE_LIMIT_KEY_USER", "RATE_LIMIT_KEY_USER_DEFAULT", "RATE_LIMIT_KEY_USER_GROUP"}, + "resources.model_services.*.config.rate_limits[*].renewal_period": {"RATE_LIMIT_RENEWAL_PERIOD_HOUR", "RATE_LIMIT_RENEWAL_PERIOD_MINUTE"}, + "resources.model_services.*.config.routing.destinations[*].destination_type": {"DESTINATION_TYPE_EXTERNAL_FOUNDATION_MODEL", "DESTINATION_TYPE_PAY_PER_TOKEN_FOUNDATION_MODEL", "DESTINATION_TYPE_PROVISIONED_THROUGHPUT_FOUNDATION_MODEL"}, + "resources.model_services.*.config.routing.fallback.destinations[*].destination_type": {"DESTINATION_TYPE_EXTERNAL_FOUNDATION_MODEL", "DESTINATION_TYPE_PAY_PER_TOKEN_FOUNDATION_MODEL", "DESTINATION_TYPE_PROVISIONED_THROUGHPUT_FOUNDATION_MODEL"}, + "resources.model_serving_endpoints.*.ai_gateway.guardrails.input.pii.behavior": {"BLOCK", "MASK", "NONE"}, "resources.model_serving_endpoints.*.ai_gateway.guardrails.output.pii.behavior": {"BLOCK", "MASK", "NONE"}, "resources.model_serving_endpoints.*.ai_gateway.rate_limits[*].key": {"endpoint", "service_principal", "user", "user_group"}, diff --git a/bundle/internal/validation/generated/required_fields.go b/bundle/internal/validation/generated/required_fields.go index 617f5a65ecc..e8dbcac0a41 100644 --- a/bundle/internal/validation/generated/required_fields.go +++ b/bundle/internal/validation/generated/required_fields.go @@ -201,6 +201,20 @@ var RequiredFields = map[string][]string{ "resources.jobs.*.webhook_notifications.on_streaming_backlog_exceeded[*]": {"id"}, "resources.jobs.*.webhook_notifications.on_success[*]": {"id"}, + "resources.model_services.*": {"parent", "model_service_id"}, + "resources.model_services.*.config.inference_table": {"parent"}, + "resources.model_services.*.config.rate_limits[*]": {"key", "renewal_period"}, + "resources.model_services.*.config.routing.destinations[*]": {"destination_type", "name"}, + "resources.model_services.*.config.routing.destinations[*].external_model_config": {"model_provider_service", "target"}, + "resources.model_services.*.config.routing.destinations[*].external_model_config.target": {"model"}, + "resources.model_services.*.config.routing.destinations[*].pay_per_token_config": {"model"}, + "resources.model_services.*.config.routing.destinations[*].provisioned_throughput_config": {"model_serving_endpoint"}, + "resources.model_services.*.config.routing.fallback.destinations[*]": {"destination_type", "name"}, + "resources.model_services.*.config.routing.fallback.destinations[*].external_model_config": {"model_provider_service", "target"}, + "resources.model_services.*.config.routing.fallback.destinations[*].external_model_config.target": {"model"}, + "resources.model_services.*.config.routing.fallback.destinations[*].pay_per_token_config": {"model"}, + "resources.model_services.*.config.routing.fallback.destinations[*].provisioned_throughput_config": {"model_serving_endpoint"}, + "resources.model_serving_endpoints.*": {"name"}, "resources.model_serving_endpoints.*.ai_gateway.fallback_config": {"enabled"}, "resources.model_serving_endpoints.*.ai_gateway.rate_limits[*]": {"renewal_period"}, diff --git a/bundle/schema/jsonschema.json b/bundle/schema/jsonschema.json index 6383b5ac2c5..5793bbaa33d 100644 --- a/bundle/schema/jsonschema.json +++ b/bundle/schema/jsonschema.json @@ -1594,6 +1594,39 @@ } ] }, + "resources.ModelService": { + "oneOf": [ + { + "type": "object", + "properties": { + "comment": { + "$ref": "#/$defs/string" + }, + "config": { + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/catalog.ModelServiceConfig" + }, + "lifecycle": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.Lifecycle" + }, + "model_service_id": { + "$ref": "#/$defs/string" + }, + "parent": { + "$ref": "#/$defs/string" + } + }, + "additionalProperties": false, + "required": [ + "parent", + "model_service_id" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\._*\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.ModelServingEndpoint": { "oneOf": [ { @@ -3564,6 +3597,9 @@ "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.Job", "markdownDescription": "The job definitions for the bundle, where each key is the name of the job. See [jobs](https://docs.databricks.com/dev-tools/bundles/resources.html#jobs)." }, + "model_services": { + "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.ModelService" + }, "model_serving_endpoints": { "description": "The model serving endpoint definitions for the bundle, where each key is the name of the model serving endpoint.", "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.ModelServingEndpoint", @@ -4896,6 +4932,288 @@ } ] }, + "catalog.InferenceTableConfig": { + "oneOf": [ + { + "type": "object", + "description": "Inference table configuration for payload logging on a model service.\n\n`parent` is always REQUIRED when the sub-message is set; the destination\nUC schema is needed to construct or rebind the payload TABLE regardless of\nwhether payload logging is currently active. Payload logging is active by\ndefault; set `disabled = true` to pause runtime logging without dropping the\ntable or the binding.", + "properties": { + "disabled": { + "description": "[Beta] Indicates whether payload logging is disabled (opt-out). Unset means that\npayload logging is active (the on-by-default state coincides with the proto\nzero-value, so the server never fills this field for a client that leaves it\nunset). Set `disabled = true` to pause runtime logging while keeping the\nsub-message attached (preserving `parent` and `table_name_prefix` for a\nlater flip back to active). `parent` remains required either way.", + "$ref": "#/$defs/bool" + }, + "parent": { + "description": "[Beta] Parent UC schema where the inference table is created.\nFormat: `schemas/{catalog}.{schema}`. Set at create time and immutable\nthereafter; changing it on an existing service is rejected.", + "$ref": "#/$defs/string" + }, + "table_name_prefix": { + "description": "[Beta] Prefix for the inference-table's UC-registered name. The actual leaf name UC\nstores is `\u003ctable_name_prefix\u003e_payload`; the `_payload` suffix is appended\nautomatically. To find the actual UC table after Create, read the `table`\nfield on the response. Defaults to `\u003cmodel_service_name\u003e_payload` when unset.\nSet at create time and immutable thereafter; changing it on an existing\nservice is rejected.", + "$ref": "#/$defs/string" + } + }, + "additionalProperties": false, + "required": [ + "parent" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\._*\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, + "catalog.ModelProviderServiceConfigModelTargetConfig": { + "oneOf": [ + { + "type": "object", + "description": "Model target configuration for an external model destination.", + "properties": { + "model": { + "description": "[Beta] Provider-side model identifier (e.g. \"gpt-5\", \"claude-opus-4-7\"). This is\na string on the LLM provider's side, not a UC entity. The UC governance\nhook for external destinations is the ModelProviderService referenced by\n`ExternalModelConfig.model_provider_service`, not the model itself.", + "$ref": "#/$defs/string" + }, + "native_api_types": { + "description": "[Beta] Provider-native API types the model supports (e.g.\n\"openai/v1/chat/completions\"). Used by the platform for request/response\ntranslation from the unified API type. At most 64 entries of at most 256\ncharacters each; the list is persisted into the destination binding's\nbounded storage envelope.", + "$ref": "#/$defs/slice/string" + } + }, + "additionalProperties": false, + "required": [ + "model" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\._*\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, + "catalog.ModelServiceConfig": { + "oneOf": [ + { + "type": "object", + "description": "Operational configuration wrapped around the ModelService resource.", + "properties": { + "inference_table": { + "description": "[Beta] Inference table config for payload logging.", + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/catalog.InferenceTableConfig" + }, + "rate_limits": { + "description": "[Beta] Rate limits applied to requests routed through this model service.", + "$ref": "#/$defs/slice/github.com/databricks/databricks-sdk-go/service/catalog.RateLimit" + }, + "routing": { + "description": "[Beta] Routing configuration: destinations, routing strategy, and fallback.", + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/catalog.ModelServiceConfigRoutingConfig" + } + }, + "additionalProperties": false + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\._*\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, + "catalog.ModelServiceConfigDestinationConfig": { + "oneOf": [ + { + "type": "object", + "description": "A destination the model service can route traffic to. Exactly one of the\nper-type configs inside `type_config` must be set, and it must match\n`destination_type`.", + "properties": { + "destination_type": { + "description": "[Beta] Backing-model category. Determines which oneof variant is populated.", + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/catalog.ModelServiceConfigDestinationConfigDestinationType" + }, + "external_model_config": { + "description": "[Beta] Configuration for an external-foundation-model destination. Provider auth\nand provider-specific cloud configuration are owned by a separate, governed\nModelProviderService entity referenced via `model_provider_service`; the\nplatform resolves the provider at invocation time.", + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/catalog.ModelServiceConfigExternalModelConfig" + }, + "name": { + "description": "[Beta] User-facing label for this destination, used in routing references.", + "$ref": "#/$defs/string" + }, + "pay_per_token_config": { + "description": "[Beta] Configuration for a pay-per-token foundation-model destination. Identifies\nthe foundation model by its UC resource name; the platform resolves it to a\nModel Serving endpoint at request time.", + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/catalog.ModelServiceConfigPayPerTokenConfig" + }, + "provisioned_throughput_config": { + "description": "[Beta] Configuration for a provisioned-throughput foundation-model destination.\nReferences a pre-existing Model Serving endpoint that serves the model;\nsizing (provisioned throughput, burst scaling, model version) is owned by\nthe Model Serving endpoint itself, not by this message.", + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/catalog.ModelServiceConfigProvisionedThroughputConfig" + }, + "traffic_percentage": { + "description": "[Beta] Share of traffic sent to this destination, 0-100. Optional on fallback\ndestinations; see FallbackConfig.", + "$ref": "#/$defs/int" + } + }, + "additionalProperties": false, + "required": [ + "destination_type", + "name" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\._*\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, + "catalog.ModelServiceConfigDestinationConfigDestinationType": { + "oneOf": [ + { + "type": "string", + "description": "Backing-model category for a model service destination.", + "enum": [ + "DESTINATION_TYPE_PAY_PER_TOKEN_FOUNDATION_MODEL", + "DESTINATION_TYPE_PROVISIONED_THROUGHPUT_FOUNDATION_MODEL", + "DESTINATION_TYPE_EXTERNAL_FOUNDATION_MODEL" + ], + "enumDescriptions": [ + "[Beta]", + "[Beta]", + "[Beta]" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\._*\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, + "catalog.ModelServiceConfigExternalModelConfig": { + "oneOf": [ + { + "type": "object", + "description": "Configuration for an external-foundation-model destination. Provider auth\nand provider-specific cloud configuration are owned by a separate, governed\nModelProviderService entity referenced via `model_provider_service`; the\nplatform resolves the provider at invocation time.", + "properties": { + "model_provider_service": { + "description": "[Beta] Resource name of the governed ModelProviderService that owns provider\nauth and provider-specific configuration. The referenced\nModelProviderService also carries the provider type, so this message\ndoes not surface it directly.\nFormat: `model-provider-services/{catalog}.{schema}.{model_provider_service}`.\nEach `{...}` component is capped at 255 characters individually.", + "$ref": "#/$defs/string" + }, + "target": { + "description": "[Beta] Routing target for the destination: the provider-side model selected from\nthe referenced ModelProviderService's `targets` catalog, plus the unified\nAPI types the platform should translate to/from at request time.", + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/catalog.ModelProviderServiceConfigModelTargetConfig" + } + }, + "additionalProperties": false, + "required": [ + "model_provider_service", + "target" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\._*\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, + "catalog.ModelServiceConfigFallbackConfig": { + "oneOf": [ + { + "type": "object", + "description": "Fallback routing, applied after the primary destination returns a retryable\nerror. Traversal is in list order; the attempt count is the length of the\nlist.", + "properties": { + "destinations": { + "description": "[Beta] Ordered list of fallback destinations. Traversal is in list order; the\nattempt count is the length of the list. At most 5 are allowed.", + "$ref": "#/$defs/slice/github.com/databricks/databricks-sdk-go/service/catalog.ModelServiceConfigDestinationConfig" + } + }, + "additionalProperties": false + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\._*\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, + "catalog.ModelServiceConfigPayPerTokenConfig": { + "oneOf": [ + { + "type": "object", + "description": "Configuration for a pay-per-token foundation-model destination. Identifies\nthe foundation model by its UC resource name; the platform resolves it to a\nModel Serving endpoint at request time.", + "properties": { + "model": { + "description": "[Beta] Resource name of the UC model.\nFormat: `models/{catalog}.{schema}.{model}`.", + "$ref": "#/$defs/string" + } + }, + "additionalProperties": false, + "required": [ + "model" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\._*\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, + "catalog.ModelServiceConfigProvisionedThroughputConfig": { + "oneOf": [ + { + "type": "object", + "description": "Configuration for a provisioned-throughput foundation-model destination.\nReferences a pre-existing Model Serving endpoint that serves the model;\nsizing (provisioned throughput, burst scaling, model version) is owned by\nthe Model Serving endpoint itself, not by this message.", + "properties": { + "model_serving_endpoint": { + "description": "[Beta] Name of the backing Model Serving endpoint serving the provisioned-\nthroughput foundation model, as the AIP-122 typed resource name\n`serving-endpoints/{name}`. The same UC model can be served on multiple\nModel Serving endpoints (different throughput / region / config); the\ncaller picks which one this destination routes to. The endpoint must\nexist at create time.", + "$ref": "#/$defs/string" + } + }, + "additionalProperties": false, + "required": [ + "model_serving_endpoint" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\._*\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, + "catalog.ModelServiceConfigRoutingConfig": { + "oneOf": [ + { + "type": "object", + "description": "Routing configuration for a model service, nesting destinations, routing\nstrategy, and fallback under a single sub-message.", + "properties": { + "destinations": { + "description": "[Beta] Primary routing destinations. At most 10 are allowed. At least one is\nrequired on CreateModelService; on UpdateModelService it is required only\nwhen `config.routing` (or a `config.routing.*` subpath) appears in\n`update_mask`.", + "$ref": "#/$defs/slice/github.com/databricks/databricks-sdk-go/service/catalog.ModelServiceConfigDestinationConfig" + }, + "fallback": { + "description": "[Beta] Fallback routing config, applied after primary destinations fail.", + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/catalog.ModelServiceConfigFallbackConfig" + }, + "first_token_timeout": { + "description": "[Beta] Timeout for the first token of a streaming response. If a destination does\nnot return its first token within this duration, AI Gateway aborts the\nattempt and fails over to the next destination. Applies to streaming\nrequests only. Leave unset for no first-token timeout.", + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/common/types/duration.Duration" + }, + "traffic_splitting": { + "description": "[Beta] Marker message selecting request-based traffic splitting. Traffic is\ndistributed according to each destination's traffic_percentage value;\nno configuration lives on this message itself.", + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/catalog.ModelServiceConfigRoutingConfigTrafficSplitting" + } + }, + "additionalProperties": false + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\._*\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, + "catalog.ModelServiceConfigRoutingConfigTrafficSplitting": { + "oneOf": [ + { + "type": "object", + "description": "Marker message selecting request-based traffic splitting across primary\ndestinations. Split weights are read from each DestinationConfig.traffic_percentage.", + "additionalProperties": false + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\._*\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "catalog.MonitorCronSchedule": { "oneOf": [ { @@ -5255,6 +5573,101 @@ } ] }, + "catalog.RateLimit": { + "oneOf": [ + { + "type": "object", + "description": "A rate limit applied to service requests. Leave `requests` or `tokens`\nunset to impose no limit on that dimension; set a value to cap that dimension\nwithin the renewal period.", + "properties": { + "key": { + "description": "[Beta] Scope key. Determines whether `principal` is required.", + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/catalog.RateLimitRateLimitKey" + }, + "principal": { + "description": "[Beta] Principal this limit applies to: user email, group name, or service\nprincipal application ID. Required unless `key` is\n`RATE_LIMIT_KEY_SERVICE`, `RATE_LIMIT_KEY_USER_DEFAULT`, or\n`RATE_LIMIT_KEY_REQUEST_TAG` (which must not set a principal).", + "$ref": "#/$defs/string" + }, + "renewal_period": { + "description": "[Beta] Renewal period.", + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/catalog.RateLimitRateLimitRenewalPeriod" + }, + "request_tag_key": { + "description": "[Beta] Request tag key this limit applies to. Required when `key` is\n`RATE_LIMIT_KEY_REQUEST_TAG`, forbidden otherwise.", + "$ref": "#/$defs/string" + }, + "request_tag_value": { + "description": "[Beta] Request tag value this limit applies to. Only valid when `key` is\n`RATE_LIMIT_KEY_REQUEST_TAG`. Leave unset to apply the limit to every\nvalue of `request_tag_key` (an any-value default); a set value is a\nspecific override for that value.", + "$ref": "#/$defs/string" + }, + "requests": { + "description": "[Beta] Max requests allowed within a renewal period. Leave unset for no request limit.", + "$ref": "#/$defs/int64" + }, + "tokens": { + "description": "[Beta] Max tokens allowed within a renewal period. Leave unset for no token limit.", + "$ref": "#/$defs/int64" + } + }, + "additionalProperties": false, + "required": [ + "key", + "renewal_period" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\._*\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, + "catalog.RateLimitRateLimitKey": { + "oneOf": [ + { + "type": "string", + "description": "Scope key for a rate limit.", + "enum": [ + "RATE_LIMIT_KEY_USER", + "RATE_LIMIT_KEY_USER_GROUP", + "RATE_LIMIT_KEY_SERVICE_PRINCIPAL", + "RATE_LIMIT_KEY_SERVICE", + "RATE_LIMIT_KEY_USER_DEFAULT", + "RATE_LIMIT_KEY_REQUEST_TAG" + ], + "enumDescriptions": [ + "[Beta]", + "[Beta]", + "[Beta]", + "[Beta]", + "[Beta]", + "[Beta]" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\._*\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, + "catalog.RateLimitRateLimitRenewalPeriod": { + "oneOf": [ + { + "type": "string", + "description": "Renewal period for a rate limit.", + "enum": [ + "RATE_LIMIT_RENEWAL_PERIOD_MINUTE", + "RATE_LIMIT_RENEWAL_PERIOD_HOUR" + ], + "enumDescriptions": [ + "[Beta]", + "[Beta]" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\._*\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "catalog.RegisteredModelAlias": { "oneOf": [ { @@ -15298,6 +15711,20 @@ } ] }, + "resources.ModelService": { + "oneOf": [ + { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.ModelService" + } + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\._*\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.ModelServingEndpoint": { "oneOf": [ { @@ -15945,6 +16372,20 @@ } ] }, + "catalog.ModelServiceConfigDestinationConfig": { + "oneOf": [ + { + "type": "array", + "items": { + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/catalog.ModelServiceConfigDestinationConfig" + } + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\._*\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "catalog.MonitorMetric": { "oneOf": [ { @@ -15987,6 +16428,20 @@ } ] }, + "catalog.RateLimit": { + "oneOf": [ + { + "type": "array", + "items": { + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/catalog.RateLimit" + } + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\._*\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "catalog.RegisteredModelAlias": { "oneOf": [ { diff --git a/libs/testserver/fake_workspace.go b/libs/testserver/fake_workspace.go index 2f077b1b631..cb671a2256d 100644 --- a/libs/testserver/fake_workspace.go +++ b/libs/testserver/fake_workspace.go @@ -204,6 +204,7 @@ type FakeWorkspace struct { Catalogs map[string]catalog.CatalogInfo ExternalLocations map[string]catalog.ExternalLocationInfo RegisteredModels map[string]catalog.RegisteredModelInfo + ModelServices map[string]catalog.ModelService ServingEndpoints map[string]serving.ServingEndpointDetailed VectorSearchEndpoints map[string]vectorsearch.EndpointInfo VectorSearchIndexes map[string]fakeVectorSearchIndex @@ -462,6 +463,7 @@ func NewFakeWorkspace(url, token string) *FakeWorkspace { ExternalLocations: map[string]catalog.ExternalLocationInfo{}, Schemas: map[string]catalog.SchemaInfo{}, RegisteredModels: map[string]catalog.RegisteredModelInfo{}, + ModelServices: map[string]catalog.ModelService{}, Volumes: map[string]catalog.VolumeInfo{}, Dashboards: NewEventualMap[string, *fakeDashboard](strings.HasPrefix(token, EventualConsistencyTokenPrefix)), PublishedDashboards: map[string]dashboards.PublishedDashboard{}, diff --git a/libs/testserver/handlers.go b/libs/testserver/handlers.go index 8c44d97eae7..2d64b6e1efe 100644 --- a/libs/testserver/handlers.go +++ b/libs/testserver/handlers.go @@ -598,6 +598,24 @@ func AddDefaultHandlers(server *Server) { return MapDelete(req.Workspace, req.Workspace.RegisteredModels, req.Vars["full_name"]) }) + // Model Services (AI Gateway): + + server.Handle("POST", "/api/2.1/unity-catalog/model-services", func(req Request) any { + return req.Workspace.ModelServicesCreate(req) + }) + + server.Handle("GET", "/api/2.1/unity-catalog/model-services/{name}", func(req Request) any { + return MapGet(req.Workspace, req.Workspace.ModelServices, req.Vars["name"]) + }) + + server.Handle("PATCH", "/api/2.1/unity-catalog/model-services/{name}", func(req Request) any { + return req.Workspace.ModelServicesUpdate(req, req.Vars["name"]) + }) + + server.Handle("DELETE", "/api/2.1/unity-catalog/model-services/{name}", func(req Request) any { + return MapDelete(req.Workspace, req.Workspace.ModelServices, req.Vars["name"]) + }) + // Volumes: server.Handle("GET", "/api/2.1/unity-catalog/volumes/{full_name}", func(req Request) any { diff --git a/libs/testserver/model_services.go b/libs/testserver/model_services.go new file mode 100644 index 00000000000..ce653543ac5 --- /dev/null +++ b/libs/testserver/model_services.go @@ -0,0 +1,77 @@ +package testserver + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/databricks/databricks-sdk-go/service/catalog" +) + +// ModelServicesCreate fakes POST /api/2.1/unity-catalog/model-services. +// +// `parent` (schemas/{catalog}.{schema}) and `model_service_id` arrive as query +// parameters; the ModelService body is sent directly (not wrapped in +// "model_service"). The server derives the resource name +// model-services/{catalog}.{schema}.{model_service}. +// The map is keyed by the {catalog}.{schema}.{model_service} portion, which is +// the path segment used on subsequent get/update/delete. +func (s *FakeWorkspace) ModelServicesCreate(req Request) Response { + defer s.LockUnlock()() + + // The SDK sends the ModelService body directly (not wrapped in + // "model_service"); parent and model_service_id are query parameters. + var ms catalog.ModelService + if err := json.Unmarshal(req.Body, &ms); err != nil { + return Response{ + Body: fmt.Sprintf("internal error: %s", err), + StatusCode: http.StatusInternalServerError, + } + } + + schema := strings.TrimPrefix(req.URL.Query().Get("parent"), "schemas/") + key := schema + "." + req.URL.Query().Get("model_service_id") + + ms.Name = "model-services/" + key + ms.CreatedBy = s.CurrentUser().UserName + ms.UpdatedBy = s.CurrentUser().UserName + ms.EffectiveOwner = s.CurrentUser().UserName + ms.MetastoreId = nextUUID() + + s.ModelServices[key] = ms + return Response{ + Body: ms, + } +} + +func (s *FakeWorkspace) ModelServicesUpdate(req Request, name string) Response { + defer s.LockUnlock()() + + existing, ok := s.ModelServices[name] + if !ok { + return Response{ + StatusCode: http.StatusNotFound, + Body: fmt.Sprintf("model service %s not found", name), + } + } + + // The SDK sends the ModelService body directly; update_mask is a query param. + var incoming catalog.ModelService + if err := json.Unmarshal(req.Body, &incoming); err != nil { + return Response{ + Body: fmt.Sprintf("internal error: %s", err), + StatusCode: http.StatusInternalServerError, + } + } + + // Apply the mutable fields carried in the update mask (comment, config). + existing.Comment = incoming.Comment + existing.Config = incoming.Config + existing.UpdatedBy = s.CurrentUser().UserName + + s.ModelServices[name] = existing + return Response{ + Body: existing, + } +}