diff --git a/docs/docs/api/appkit/Enumeration.ResourceType.md b/docs/docs/api/appkit/Enumeration.ResourceType.md index 7d8c6f5de..7803d4dc9 100644 --- a/docs/docs/api/appkit/Enumeration.ResourceType.md +++ b/docs/docs/api/appkit/Enumeration.ResourceType.md @@ -44,6 +44,14 @@ JOB: "job"; *** +### MODEL\_SERVICE + +```ts +MODEL_SERVICE: "model_service"; +``` + +*** + ### POSTGRES ```ts diff --git a/docs/docs/api/appkit/TypeAlias.ResourcePermission.md b/docs/docs/api/appkit/TypeAlias.ResourcePermission.md index 484c9f1d4..1879a434b 100644 --- a/docs/docs/api/appkit/TypeAlias.ResourcePermission.md +++ b/docs/docs/api/appkit/TypeAlias.ResourcePermission.md @@ -6,6 +6,7 @@ type ResourcePermission = | JobPermission | SqlWarehousePermission | ServingEndpointPermission + | ModelServicePermission | VolumePermission | VectorSearchIndexPermission | UcFunctionPermission diff --git a/docs/docs/plugins/agents.md b/docs/docs/plugins/agents.md index bdcb5c0c9..2e633631a 100644 --- a/docs/docs/plugins/agents.md +++ b/docs/docs/plugins/agents.md @@ -13,7 +13,7 @@ This page covers the full lifecycle. For the hand-written primitives (`tool()`, ## Requirements :::info Streaming-capable serving endpoints only -The agents plugin drives the LLM over Server-Sent Events. Foundation Model APIs (Claude, Llama, GPT, etc.) and other chat-style endpoints support streaming and work out of the box. Custom model endpoints that return a single JSON response (e.g. typical `sklearn` or MLflow `pyfunc` deployments) do **not** stream — pointing an agent at one will fail with "Response body is null — streaming not supported" on the first turn. If you list a serving endpoint in `apps init`, pick one whose model implements the chat-completions streaming protocol; the agents plugin reads its name from `DATABRICKS_SERVING_ENDPOINT_NAME` whenever an agent doesn't pin `model:` itself. +The agents plugin drives the LLM over Server-Sent Events. Foundation Model APIs (Claude, Llama, GPT, etc.) and other chat-style endpoints support streaming and work out of the box. Custom model endpoints that return a single JSON response (e.g. typical `sklearn` or MLflow `pyfunc` deployments) do **not** stream — pointing an agent at one will fail with "Response body is null — streaming not supported" on the first turn. Whenever an agent doesn't pin `model:` itself, the agents plugin uses its optional `model_service` resource: a Unity Catalog model service (`EXECUTE`) whose full name (`catalog.schema.name`) it reads from `DATABRICKS_MODEL_SERVICE_NAME` and calls through the AI Gateway. The legacy `DATABRICKS_SERVING_ENDPOINT_NAME` is still read as a fallback. A string `model` containing a dot routes to the AI Gateway; any other name is a serving endpoint. `fromModelServing` (the serving-endpoint adapter) uses the OpenAI **chat-completions** streaming protocol, so it drives chat-completions serving endpoints (task type `agent/*/chat`) and Databricks foundation models. An endpoint on the Responses API (task type `agent/*/responses`, e.g. a `ResponsesAgent`) rejects a chat-completions request, so `fromModelServing` can't drive it. @@ -67,7 +67,7 @@ On startup the plugin: 1. Discovers `server/agents/assistant/agent.md` and registers agent id `assistant`. 2. Parses the YAML frontmatter and markdown body as the agent's `instructions`. -3. Resolves the adapter from `endpoint` (or falls back to `DATABRICKS_SERVING_ENDPOINT_NAME`). +3. Resolves the adapter from `endpoint` (or falls back to `DATABRICKS_MODEL_SERVICE_NAME`, then `DATABRICKS_SERVING_ENDPOINT_NAME`). 4. Mounts the agent at the default name (`assistant`). The agent starts with **no tools**. Tools are opt-in — declare them in frontmatter (Level 2 below) or opt into auto-inherit explicitly with `agents({ autoInheritTools: { file: true } })`. See "Auto-inherit posture" further down for what that costs and why it's off by default. @@ -348,7 +348,7 @@ An on-behalf-of-user agent fails closed: **Exceptions.** MLflow tracing, the thread store, and catalog skills (see [Catalog skills](#catalog-skills-unity-catalog-volume)) are app-owned and stay service principal in every mode. Thread rows are keyed by the user id. -**Pre-built adapters.** The user's client is applied when AppKit builds the adapter from a model string (`model: "my-endpoint"`, `defaultModel`, or `DATABRICKS_SERVING_ENDPOINT_NAME`). If you build a `DatabricksAdapter` yourself with a fixed `workspaceClient`, an on-behalf-of-user agent throws at boot instead of running the model as the service principal. Pass a provider so the client resolves per call, or use a model string. Mixed agents accept fixed-client adapters as before: +**Pre-built adapters.** The user's client is applied when AppKit builds the adapter from a model string (`model: "my-endpoint"`, `defaultModel`, `DATABRICKS_MODEL_SERVICE_NAME` or `DATABRICKS_SERVING_ENDPOINT_NAME`). If you build a `DatabricksAdapter` yourself with a fixed `workspaceClient`, an on-behalf-of-user agent throws at boot instead of running the model as the service principal. Pass a provider so the client resolves per call, or use a model string. Mixed agents accept fixed-client adapters as before: ```ts DatabricksAdapter.fromModelServing("my-endpoint", { diff --git a/docs/docs/plugins/manifest.md b/docs/docs/plugins/manifest.md index 33f941ff1..89a7f9ac0 100644 --- a/docs/docs/plugins/manifest.md +++ b/docs/docs/plugins/manifest.md @@ -66,6 +66,7 @@ A resource requirement declares one Databricks resource the plugin depends on. T | `job` | `CAN_VIEW`, `CAN_MANAGE_RUN`, `CAN_MANAGE` | | `sql_warehouse` | `CAN_USE`, `CAN_MANAGE` | | `serving_endpoint` | `CAN_VIEW`, `CAN_QUERY`, `CAN_MANAGE` | +| `model_service` | `EXECUTE` | | `volume` | `READ_VOLUME`, `WRITE_VOLUME` | | `vector_search_index` | `SELECT` | | `uc_function` | `EXECUTE` | diff --git a/docs/static/schemas/plugin-manifest.schema.json b/docs/static/schemas/plugin-manifest.schema.json index 0cad35eff..b21d49062 100644 --- a/docs/static/schemas/plugin-manifest.schema.json +++ b/docs/static/schemas/plugin-manifest.schema.json @@ -706,6 +706,169 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "model_service" + }, + "alias": { + "type": "string", + "minLength": 1, + "description": "Human-readable label for UI/display only. Deduplication uses resourceKey, not alias." + }, + "resourceKey": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "Stable key for machine use: deduplication, env naming, composite keys, app.yaml. Required for registry lookup." + }, + "description": { + "type": "string", + "minLength": 1, + "description": "Human-readable description of why this resource is needed" + }, + "fields": { + "description": "Map of field name to env and optional description. Single-value types use one key (e.g. id); multi-value (database, secret) use multiple (e.g. instance_name, database_name or scope, key).", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "env": { + "description": "Environment variable name for this field", + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$" + }, + "description": { + "description": "Human-readable description for this field", + "type": "string" + }, + "bundleIgnore": { + "description": "When true, this field is excluded from Databricks bundle configuration (databricks.yml) generation.", + "type": "boolean" + }, + "examples": { + "description": "Example values showing the expected format for this field", + "type": "array", + "items": { + "type": "string" + } + }, + "localOnly": { + "description": "When true, this field is only generated for local .env files. The Databricks Apps platform auto-injects it at deploy time.", + "type": "boolean" + }, + "value": { + "description": "Static value for this field. Used when no prompted or resolved value exists.", + "type": "string" + }, + "resolve": { + "description": "Named resolver prefixed by resource type (e.g., 'postgres:host'). The CLI resolves this value during the init prompt flow.", + "type": "string", + "pattern": "^[a-z_]+:[a-zA-Z]+$" + }, + "discovery": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "kind", + "description": "Discriminator: 'kind' uses the AppKit-owned command map for the named resourceKind." + }, + "resourceKind": { + "type": "string", + "enum": [ + "warehouse", + "genie_space", + "postgres_project", + "postgres_branch", + "postgres_database", + "volume" + ], + "description": "Reference to a well-known Databricks resource kind. AppKit owns the CLI command, response shape, and unwrap rules." + }, + "select": { + "description": "Field name in the parsed CLI response used as the selected value (e.g., 'id'). Defaults to the kind's natural identifier when omitted.", + "type": "string" + }, + "display": { + "description": "Field name in the parsed CLI response shown to the user in selection UI. Defaults to `select` if omitted.", + "type": "string" + }, + "dependsOn": { + "description": "Name of a sibling field within the same resource that must be resolved first. Used to express ordering dependencies between resource fields.", + "type": "string" + }, + "shortcut": { + "description": "Single-value fast-path command that returns exactly one value, skipping interactive selection.", + "type": "string" + } + }, + "required": ["type", "resourceKind"], + "additionalProperties": false, + "description": "Discovery via a well-known resource kind. AppKit owns the CLI command and unwrap rules for the named kind." + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "cli", + "description": "Discriminator: 'cli' uses a free-form Databricks CLI command supplied by the plugin." + }, + "cliCommand": { + "type": "string", + "description": "Databricks CLI command that lists resources. Must include . Shell metacharacters (;|&`$ and newlines) are rejected; for first-party Databricks resources prefer the `kind` variant which uses AppKit's typed command map." + }, + "selectField": { + "type": "string", + "description": "jq-style path to the field used as the selected value (e.g., '.id', '.name')." + }, + "displayField": { + "description": "jq-style path to the field shown to the user in selection UI. Defaults to selectField if omitted.", + "type": "string" + }, + "dependsOn": { + "description": "Name of a sibling field within the same resource that must be resolved first. Used to express ordering dependencies between resource fields.", + "type": "string" + }, + "shortcut": { + "description": "Single-value fast-path command that returns exactly one value, skipping interactive selection. Shell metacharacters are rejected.", + "type": "string" + } + }, + "required": ["type", "cliCommand", "selectField"], + "additionalProperties": false, + "description": "Discovery via a free-form Databricks CLI command. Escape hatch — prefer the `kind` variant when a typed resourceKind covers the resource. This shape is intentionally minimal and may tighten further in future versions." + } + ], + "description": "Describes how the CLI discovers values for a resource field. 'kind' references a well-known Databricks resource kind whose command is owned by AppKit; 'cli' is the escape hatch carrying a free-form Databricks CLI command." + } + }, + "additionalProperties": false, + "description": "Defines a single field for a resource. Each field has its own environment variable and optional description. Single-value types use one key (e.g. id); multi-value types (database, secret) use multiple (e.g. instance_name, database_name or scope, key)." + } + }, + "permission": { + "type": "string", + "enum": ["EXECUTE"], + "description": "Required permission level. Validated per resource type." + } + }, + "required": [ + "type", + "alias", + "resourceKey", + "description", + "permission" + ], + "additionalProperties": false + }, { "type": "object", "properties": { @@ -2834,6 +2997,169 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "model_service" + }, + "alias": { + "type": "string", + "minLength": 1, + "description": "Human-readable label for UI/display only. Deduplication uses resourceKey, not alias." + }, + "resourceKey": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "Stable key for machine use: deduplication, env naming, composite keys, app.yaml. Required for registry lookup." + }, + "description": { + "type": "string", + "minLength": 1, + "description": "Human-readable description of why this resource is needed" + }, + "fields": { + "description": "Map of field name to env and optional description. Single-value types use one key (e.g. id); multi-value (database, secret) use multiple (e.g. instance_name, database_name or scope, key).", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "env": { + "description": "Environment variable name for this field", + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$" + }, + "description": { + "description": "Human-readable description for this field", + "type": "string" + }, + "bundleIgnore": { + "description": "When true, this field is excluded from Databricks bundle configuration (databricks.yml) generation.", + "type": "boolean" + }, + "examples": { + "description": "Example values showing the expected format for this field", + "type": "array", + "items": { + "type": "string" + } + }, + "localOnly": { + "description": "When true, this field is only generated for local .env files. The Databricks Apps platform auto-injects it at deploy time.", + "type": "boolean" + }, + "value": { + "description": "Static value for this field. Used when no prompted or resolved value exists.", + "type": "string" + }, + "resolve": { + "description": "Named resolver prefixed by resource type (e.g., 'postgres:host'). The CLI resolves this value during the init prompt flow.", + "type": "string", + "pattern": "^[a-z_]+:[a-zA-Z]+$" + }, + "discovery": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "kind", + "description": "Discriminator: 'kind' uses the AppKit-owned command map for the named resourceKind." + }, + "resourceKind": { + "type": "string", + "enum": [ + "warehouse", + "genie_space", + "postgres_project", + "postgres_branch", + "postgres_database", + "volume" + ], + "description": "Reference to a well-known Databricks resource kind. AppKit owns the CLI command, response shape, and unwrap rules." + }, + "select": { + "description": "Field name in the parsed CLI response used as the selected value (e.g., 'id'). Defaults to the kind's natural identifier when omitted.", + "type": "string" + }, + "display": { + "description": "Field name in the parsed CLI response shown to the user in selection UI. Defaults to `select` if omitted.", + "type": "string" + }, + "dependsOn": { + "description": "Name of a sibling field within the same resource that must be resolved first. Used to express ordering dependencies between resource fields.", + "type": "string" + }, + "shortcut": { + "description": "Single-value fast-path command that returns exactly one value, skipping interactive selection.", + "type": "string" + } + }, + "required": ["type", "resourceKind"], + "additionalProperties": false, + "description": "Discovery via a well-known resource kind. AppKit owns the CLI command and unwrap rules for the named kind." + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "cli", + "description": "Discriminator: 'cli' uses a free-form Databricks CLI command supplied by the plugin." + }, + "cliCommand": { + "type": "string", + "description": "Databricks CLI command that lists resources. Must include . Shell metacharacters (;|&`$ and newlines) are rejected; for first-party Databricks resources prefer the `kind` variant which uses AppKit's typed command map." + }, + "selectField": { + "type": "string", + "description": "jq-style path to the field used as the selected value (e.g., '.id', '.name')." + }, + "displayField": { + "description": "jq-style path to the field shown to the user in selection UI. Defaults to selectField if omitted.", + "type": "string" + }, + "dependsOn": { + "description": "Name of a sibling field within the same resource that must be resolved first. Used to express ordering dependencies between resource fields.", + "type": "string" + }, + "shortcut": { + "description": "Single-value fast-path command that returns exactly one value, skipping interactive selection. Shell metacharacters are rejected.", + "type": "string" + } + }, + "required": ["type", "cliCommand", "selectField"], + "additionalProperties": false, + "description": "Discovery via a free-form Databricks CLI command. Escape hatch — prefer the `kind` variant when a typed resourceKind covers the resource. This shape is intentionally minimal and may tighten further in future versions." + } + ], + "description": "Describes how the CLI discovers values for a resource field. 'kind' references a well-known Databricks resource kind whose command is owned by AppKit; 'cli' is the escape hatch carrying a free-form Databricks CLI command." + } + }, + "additionalProperties": false, + "description": "Defines a single field for a resource. Each field has its own environment variable and optional description. Single-value types use one key (e.g. id); multi-value types (database, secret) use multiple (e.g. instance_name, database_name or scope, key)." + } + }, + "permission": { + "type": "string", + "enum": ["EXECUTE"], + "description": "Required permission level. Validated per resource type." + } + }, + "required": [ + "type", + "alias", + "resourceKey", + "description", + "permission" + ], + "additionalProperties": false + }, { "type": "object", "properties": { diff --git a/docs/static/schemas/template-plugins.schema.json b/docs/static/schemas/template-plugins.schema.json index 9f18b6931..3c080b450 100644 --- a/docs/static/schemas/template-plugins.schema.json +++ b/docs/static/schemas/template-plugins.schema.json @@ -1026,6 +1026,236 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "model_service" + }, + "alias": { + "type": "string", + "minLength": 1, + "description": "Human-readable label for UI/display only." + }, + "resourceKey": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "Stable key for machine use: deduplication, env naming, composite keys." + }, + "description": { + "type": "string", + "minLength": 1, + "description": "Human-readable description of why this resource is needed" + }, + "fields": { + "description": "Map of field name to field entry with computed origin.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "env": { + "description": "Environment variable name for this field", + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$" + }, + "description": { + "description": "Human-readable description for this field", + "type": "string" + }, + "bundleIgnore": { + "description": "When true, this field is excluded from Databricks bundle configuration (databricks.yml) generation.", + "type": "boolean" + }, + "examples": { + "description": "Example values showing the expected format for this field", + "type": "array", + "items": { + "type": "string" + } + }, + "localOnly": { + "description": "When true, this field is only generated for local .env files. The Databricks Apps platform auto-injects it at deploy time.", + "type": "boolean" + }, + "value": { + "description": "Static value for this field. Used when no prompted or resolved value exists.", + "type": "string" + }, + "resolve": { + "description": "Named resolver prefixed by resource type (e.g., 'postgres:host'). The CLI resolves this value during the init prompt flow.", + "type": "string", + "pattern": "^[a-z_]+:[a-zA-Z]+$" + }, + "discovery": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "kind", + "description": "Discriminator: 'kind' uses the AppKit-owned command map for the named resourceKind." + }, + "resourceKind": { + "type": "string", + "enum": [ + "warehouse", + "genie_space", + "postgres_project", + "postgres_branch", + "postgres_database", + "volume" + ], + "description": "Reference to a well-known Databricks resource kind. AppKit owns the CLI command, response shape, and unwrap rules." + }, + "select": { + "description": "Field name in the parsed CLI response used as the selected value (e.g., 'id'). Defaults to the kind's natural identifier when omitted.", + "type": "string" + }, + "display": { + "description": "Field name in the parsed CLI response shown to the user in selection UI. Defaults to `select` if omitted.", + "type": "string" + }, + "dependsOn": { + "description": "Name of a sibling field within the same resource that must be resolved first. Used to express ordering dependencies between resource fields.", + "type": "string" + }, + "shortcut": { + "description": "Single-value fast-path command that returns exactly one value, skipping interactive selection.", + "type": "string" + } + }, + "required": ["type", "resourceKind"], + "additionalProperties": false, + "description": "Discovery via a well-known resource kind. AppKit owns the CLI command and unwrap rules for the named kind." + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "cli", + "description": "Discriminator: 'cli' uses a free-form Databricks CLI command supplied by the plugin." + }, + "cliCommand": { + "type": "string", + "description": "Databricks CLI command that lists resources. Must include . Shell metacharacters (;|&`$ and newlines) are rejected; for first-party Databricks resources prefer the `kind` variant which uses AppKit's typed command map." + }, + "selectField": { + "type": "string", + "description": "jq-style path to the field used as the selected value (e.g., '.id', '.name')." + }, + "displayField": { + "description": "jq-style path to the field shown to the user in selection UI. Defaults to selectField if omitted.", + "type": "string" + }, + "dependsOn": { + "description": "Name of a sibling field within the same resource that must be resolved first. Used to express ordering dependencies between resource fields.", + "type": "string" + }, + "shortcut": { + "description": "Single-value fast-path command that returns exactly one value, skipping interactive selection. Shell metacharacters are rejected.", + "type": "string" + } + }, + "required": [ + "type", + "cliCommand", + "selectField" + ], + "additionalProperties": false, + "description": "Discovery via a free-form Databricks CLI command. Escape hatch — prefer the `kind` variant when a typed resourceKind covers the resource. This shape is intentionally minimal and may tighten further in future versions." + } + ], + "description": "Describes how the CLI discovers values for a resource field. 'kind' references a well-known Databricks resource kind whose command is owned by AppKit; 'cli' is the escape hatch carrying a free-form Databricks CLI command." + }, + "origin": { + "type": "string", + "enum": ["user", "platform", "static", "cli"], + "description": "How the field value is determined. Computed during sync, not authored by plugin developers." + } + }, + "additionalProperties": false + } + }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ], + "additionalItems": false, + "minItems": 2, + "maxItems": 2 + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ], + "additionalItems": false, + "minItems": 2, + "maxItems": 2 + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, + "permission": { + "type": "string", + "enum": ["EXECUTE"], + "description": "Required permission level. Validated per resource type." + } + }, + "required": [ + "type", + "alias", + "resourceKey", + "description", + "permission" + ], + "additionalProperties": false + }, { "type": "object", "properties": { @@ -4030,6 +4260,236 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "model_service" + }, + "alias": { + "type": "string", + "minLength": 1, + "description": "Human-readable label for UI/display only." + }, + "resourceKey": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "Stable key for machine use: deduplication, env naming, composite keys." + }, + "description": { + "type": "string", + "minLength": 1, + "description": "Human-readable description of why this resource is needed" + }, + "fields": { + "description": "Map of field name to field entry with computed origin.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "env": { + "description": "Environment variable name for this field", + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$" + }, + "description": { + "description": "Human-readable description for this field", + "type": "string" + }, + "bundleIgnore": { + "description": "When true, this field is excluded from Databricks bundle configuration (databricks.yml) generation.", + "type": "boolean" + }, + "examples": { + "description": "Example values showing the expected format for this field", + "type": "array", + "items": { + "type": "string" + } + }, + "localOnly": { + "description": "When true, this field is only generated for local .env files. The Databricks Apps platform auto-injects it at deploy time.", + "type": "boolean" + }, + "value": { + "description": "Static value for this field. Used when no prompted or resolved value exists.", + "type": "string" + }, + "resolve": { + "description": "Named resolver prefixed by resource type (e.g., 'postgres:host'). The CLI resolves this value during the init prompt flow.", + "type": "string", + "pattern": "^[a-z_]+:[a-zA-Z]+$" + }, + "discovery": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "kind", + "description": "Discriminator: 'kind' uses the AppKit-owned command map for the named resourceKind." + }, + "resourceKind": { + "type": "string", + "enum": [ + "warehouse", + "genie_space", + "postgres_project", + "postgres_branch", + "postgres_database", + "volume" + ], + "description": "Reference to a well-known Databricks resource kind. AppKit owns the CLI command, response shape, and unwrap rules." + }, + "select": { + "description": "Field name in the parsed CLI response used as the selected value (e.g., 'id'). Defaults to the kind's natural identifier when omitted.", + "type": "string" + }, + "display": { + "description": "Field name in the parsed CLI response shown to the user in selection UI. Defaults to `select` if omitted.", + "type": "string" + }, + "dependsOn": { + "description": "Name of a sibling field within the same resource that must be resolved first. Used to express ordering dependencies between resource fields.", + "type": "string" + }, + "shortcut": { + "description": "Single-value fast-path command that returns exactly one value, skipping interactive selection.", + "type": "string" + } + }, + "required": ["type", "resourceKind"], + "additionalProperties": false, + "description": "Discovery via a well-known resource kind. AppKit owns the CLI command and unwrap rules for the named kind." + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "cli", + "description": "Discriminator: 'cli' uses a free-form Databricks CLI command supplied by the plugin." + }, + "cliCommand": { + "type": "string", + "description": "Databricks CLI command that lists resources. Must include . Shell metacharacters (;|&`$ and newlines) are rejected; for first-party Databricks resources prefer the `kind` variant which uses AppKit's typed command map." + }, + "selectField": { + "type": "string", + "description": "jq-style path to the field used as the selected value (e.g., '.id', '.name')." + }, + "displayField": { + "description": "jq-style path to the field shown to the user in selection UI. Defaults to selectField if omitted.", + "type": "string" + }, + "dependsOn": { + "description": "Name of a sibling field within the same resource that must be resolved first. Used to express ordering dependencies between resource fields.", + "type": "string" + }, + "shortcut": { + "description": "Single-value fast-path command that returns exactly one value, skipping interactive selection. Shell metacharacters are rejected.", + "type": "string" + } + }, + "required": [ + "type", + "cliCommand", + "selectField" + ], + "additionalProperties": false, + "description": "Discovery via a free-form Databricks CLI command. Escape hatch — prefer the `kind` variant when a typed resourceKind covers the resource. This shape is intentionally minimal and may tighten further in future versions." + } + ], + "description": "Describes how the CLI discovers values for a resource field. 'kind' references a well-known Databricks resource kind whose command is owned by AppKit; 'cli' is the escape hatch carrying a free-form Databricks CLI command." + }, + "origin": { + "type": "string", + "enum": ["user", "platform", "static", "cli"], + "description": "How the field value is determined. Computed during sync, not authored by plugin developers." + } + }, + "additionalProperties": false + } + }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ], + "additionalItems": false, + "minItems": 2, + "maxItems": 2 + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ], + "additionalItems": false, + "minItems": 2, + "maxItems": 2 + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, + "permission": { + "type": "string", + "enum": ["EXECUTE"], + "description": "Required permission level. Validated per resource type." + } + }, + "required": [ + "type", + "alias", + "resourceKey", + "description", + "permission" + ], + "additionalProperties": false + }, { "type": "object", "properties": { diff --git a/packages/appkit/src/agents/databricks.ts b/packages/appkit/src/agents/databricks.ts index 803723fc2..2ea2c0ce8 100644 --- a/packages/appkit/src/agents/databricks.ts +++ b/packages/appkit/src/agents/databricks.ts @@ -993,25 +993,26 @@ type ModelStringOptions = Pick< /** * Resolves a string `model` to a {@link DatabricksAdapter}, routing by name: * - * - UC model names (`system.*`, e.g. `"system.ai.claude-opus-5-5"`) → AI - * Gateway ({@link DatabricksAdapter.fromAiGateway}); the model is named in - * the request body. + * - UC model service names (`catalog.schema.name`, e.g. + * `"system.ai.claude-opus-5-5"`) → AI Gateway + * ({@link DatabricksAdapter.fromAiGateway}); the model is named in the + * request body. * - Everything else — serving-endpoint names like * `"databricks-claude-sonnet-4-5"` or a custom endpoint → Model Serving * ({@link DatabricksAdapter.fromModelServing}); the name goes in the URL. * - * Serving-endpoint names are `[a-zA-Z0-9_-]` (no dots), so the `system.` - * prefix cleanly separates the two namespaces and no existing endpoint name - * changes routing. This is the single decision point shared by the agents - * plugin and standalone `runAgent`, so the two never drift. To force a - * `system.*` name onto a serving endpoint instead, pass a pre-built adapter + * Serving-endpoint names are `[a-zA-Z0-9_-]` (no dots), so a dot cleanly + * separates the two namespaces and no existing endpoint name changes routing. + * This is the single decision point shared by the agents plugin and + * standalone `runAgent`, so the two never drift. To force a + * dotted name onto a serving endpoint instead, pass a pre-built adapter * (`DatabricksAdapter.fromServingEndpoint(...)`) as the agent's `model`. */ export function adapterFromModelString( model: string, options?: ModelStringOptions, ): Promise { - return model.startsWith("system.") + return model.includes(".") ? DatabricksAdapter.fromAiGateway({ model, ...options }) : DatabricksAdapter.fromModelServing(model, options); } diff --git a/packages/appkit/src/agents/tests/databricks.test.ts b/packages/appkit/src/agents/tests/databricks.test.ts index e4e2ff833..731319184 100644 --- a/packages/appkit/src/agents/tests/databricks.test.ts +++ b/packages/appkit/src/agents/tests/databricks.test.ts @@ -1349,7 +1349,21 @@ describe("adapterFromModelString", () => { expect(serving).not.toHaveBeenCalled(); }); - test("routes non-`system` names to Model Serving", async () => { + test("routes user model service names to the AI Gateway", async () => { + const gateway = vi + .spyOn(DatabricksAdapter, "fromAiGateway") + .mockResolvedValue({} as unknown as DatabricksAdapter); + const serving = vi + .spyOn(DatabricksAdapter, "fromModelServing") + .mockResolvedValue({} as unknown as DatabricksAdapter); + + await adapterFromModelString("main.default.my-chat"); + + expect(gateway).toHaveBeenCalledWith({ model: "main.default.my-chat" }); + expect(serving).not.toHaveBeenCalled(); + }); + + test("routes non-dotted names to Model Serving", async () => { const gateway = vi .spyOn(DatabricksAdapter, "fromAiGateway") .mockResolvedValue({} as unknown as DatabricksAdapter); diff --git a/packages/appkit/src/core/agent/run-agent.ts b/packages/appkit/src/core/agent/run-agent.ts index df67d93a5..efea6f6f8 100644 --- a/packages/appkit/src/core/agent/run-agent.ts +++ b/packages/appkit/src/core/agent/run-agent.ts @@ -340,10 +340,11 @@ async function resolveAdapter( def: AgentDefinition, onBehalfOfUser: boolean, ): Promise { - // Explicit model wins; otherwise fall back to the - // DATABRICKS_SERVING_ENDPOINT_NAME env default. A string from either source - // routes by name (system.* → AI Gateway, else Model Serving). - const source = def.model ?? process.env.DATABRICKS_SERVING_ENDPOINT_NAME; + // Explicit model wins, then the model_service env, then the legacy serving env. + const source = + def.model ?? + process.env.DATABRICKS_MODEL_SERVICE_NAME ?? + process.env.DATABRICKS_SERVING_ENDPOINT_NAME; if (!source) { // No model and no env default: let fromModelServing() surface the // canonical "set DATABRICKS_SERVING_ENDPOINT_NAME" error. diff --git a/packages/appkit/src/plugins/agents/agents.ts b/packages/appkit/src/plugins/agents/agents.ts index f3a4508ee..d4b8edad5 100644 --- a/packages/appkit/src/plugins/agents/agents.ts +++ b/packages/appkit/src/plugins/agents/agents.ts @@ -570,13 +570,11 @@ export class AgentsPlugin extends Plugin implements ToolProvider { def: AgentDefinition, name: string, ): Promise { - // Explicit model (adapter or string) wins; otherwise fall back to the - // DATABRICKS_SERVING_ENDPOINT_NAME env default. A string from any source - // routes by name in `adapterFromModelString` (system.* → AI Gateway, - // everything else → Model Serving). + // Explicit model wins, then the model_service env, then the legacy serving env. const source = def.model ?? this.config.defaultModel ?? + process.env.DATABRICKS_MODEL_SERVICE_NAME ?? process.env.DATABRICKS_SERVING_ENDPOINT_NAME; // Per-agent adapter knobs from `AgentDefinition` / markdown frontmatter. // Only applied when AppKit builds the adapter itself (string or omitted @@ -593,7 +591,7 @@ export class AgentsPlugin extends Plugin implements ToolProvider { if (!source) { throw new Error( - `Agent '${name}' has no model configured and no DATABRICKS_SERVING_ENDPOINT_NAME default available`, + `Agent '${name}' has no model configured and no DATABRICKS_MODEL_SERVICE_NAME default available`, ); } if (typeof source === "string") { diff --git a/packages/appkit/src/plugins/agents/manifest.json b/packages/appkit/src/plugins/agents/manifest.json index 529c640bc..7279d027c 100644 --- a/packages/appkit/src/plugins/agents/manifest.json +++ b/packages/appkit/src/plugins/agents/manifest.json @@ -8,15 +8,15 @@ "required": [], "optional": [ { - "type": "serving_endpoint", + "type": "model_service", "alias": "Default LLM for agents", - "resourceKey": "agents-serving-endpoint", - "description": "Default streaming-capable LLM endpoint for agents that don't pin their own model", - "permission": "CAN_QUERY", + "resourceKey": "agents-model-service", + "description": "Default Unity Catalog model service, called through AI Gateway, for agents that don't pin their own model", + "permission": "EXECUTE", "fields": { "name": { - "env": "DATABRICKS_SERVING_ENDPOINT_NAME", - "description": "Default LLM serving endpoint name" + "env": "DATABRICKS_MODEL_SERVICE_NAME", + "description": "Default model service full name (catalog.schema.name)" } } }, diff --git a/packages/appkit/src/plugins/agents/tests/agents-plugin.test.ts b/packages/appkit/src/plugins/agents/tests/agents-plugin.test.ts index ee438c1d6..fa6f556d9 100644 --- a/packages/appkit/src/plugins/agents/tests/agents-plugin.test.ts +++ b/packages/appkit/src/plugins/agents/tests/agents-plugin.test.ts @@ -143,6 +143,27 @@ describe("AgentsPlugin", () => { expect(api.getDefault()).toBe("support"); }); + test("defaults to the model_service env through the AI Gateway", async () => { + const { DatabricksAdapter } = await import("../../../agents/databricks"); + const gateway = vi + .spyOn(DatabricksAdapter, "fromAiGateway") + .mockResolvedValue(stubAdapter() as never); + vi.stubEnv("DATABRICKS_MODEL_SERVICE_NAME", "main.default.chat"); + vi.stubEnv("DATABRICKS_SERVING_ENDPOINT_NAME", "legacy-endpoint"); + try { + const plugin = instantiate({ + agents: { support: { instructions: "Help." } }, + }); + await plugin.setup(); + expect(gateway).toHaveBeenCalledWith( + expect.objectContaining({ model: "main.default.chat" }), + ); + } finally { + vi.unstubAllEnvs(); + gateway.mockRestore(); + } + }); + test("loads markdown agents from a directory", async () => { writeMarkdownAgent( tmpDir, diff --git a/packages/appkit/src/registry/types.generated.ts b/packages/appkit/src/registry/types.generated.ts index 5739f0a9c..d6e57944e 100644 --- a/packages/appkit/src/registry/types.generated.ts +++ b/packages/appkit/src/registry/types.generated.ts @@ -7,6 +7,7 @@ export enum ResourceType { JOB = "job", SQL_WAREHOUSE = "sql_warehouse", SERVING_ENDPOINT = "serving_endpoint", + MODEL_SERVICE = "model_service", VOLUME = "volume", VECTOR_SEARCH_INDEX = "vector_search_index", UC_FUNCTION = "uc_function", @@ -33,6 +34,9 @@ export type SqlWarehousePermission = "CAN_USE" | "CAN_MANAGE"; /** Permissions for SERVING_ENDPOINT resources */ export type ServingEndpointPermission = "CAN_VIEW" | "CAN_QUERY" | "CAN_MANAGE"; +/** Permissions for MODEL_SERVICE resources */ +export type ModelServicePermission = "EXECUTE"; + /** Permissions for VOLUME resources */ export type VolumePermission = "READ_VOLUME" | "WRITE_VOLUME"; @@ -70,6 +74,7 @@ export type ResourcePermission = | JobPermission | SqlWarehousePermission | ServingEndpointPermission + | ModelServicePermission | VolumePermission | VectorSearchIndexPermission | UcFunctionPermission @@ -89,6 +94,7 @@ export const PERMISSION_HIERARCHY_BY_TYPE: Record< [ResourceType.JOB]: ["CAN_VIEW", "CAN_MANAGE_RUN", "CAN_MANAGE"], [ResourceType.SQL_WAREHOUSE]: ["CAN_USE", "CAN_MANAGE"], [ResourceType.SERVING_ENDPOINT]: ["CAN_VIEW", "CAN_QUERY", "CAN_MANAGE"], + [ResourceType.MODEL_SERVICE]: ["EXECUTE"], [ResourceType.VOLUME]: ["READ_VOLUME", "WRITE_VOLUME"], [ResourceType.VECTOR_SEARCH_INDEX]: ["SELECT"], [ResourceType.UC_FUNCTION]: ["EXECUTE"], diff --git a/packages/shared/src/cli/commands/doctor/README.md b/packages/shared/src/cli/commands/doctor/README.md index 67b6a18b1..cca47b08a 100644 --- a/packages/shared/src/cli/commands/doctor/README.md +++ b/packages/shared/src/cli/commands/doctor/README.md @@ -304,7 +304,7 @@ deployment). ## Existence coverage Control-plane `.get()` for `sql_warehouse`, `serving_endpoint`, `genie_space`, -`job`, `volume`, `vector_search_index`, and `uc_function`; a real `SELECT 1` +`job`, `volume`, `vector_search_index`, `uc_function`, and `model_service`; a real `SELECT 1` connection for `postgres`/Lakebase. Other types report `skipped`. Requires live credentials (a configured Databricks profile/token). diff --git a/packages/shared/src/cli/commands/doctor/checks-existence.test.ts b/packages/shared/src/cli/commands/doctor/checks-existence.test.ts index 1b7d72813..316ccdb6a 100644 --- a/packages/shared/src/cli/commands/doctor/checks-existence.test.ts +++ b/packages/shared/src/cli/commands/doctor/checks-existence.test.ts @@ -363,6 +363,33 @@ describe("runExistenceProbe — per-type coverage with real manifest keys", () = expect(getFunction).toHaveBeenCalledWith({ fullNameArg: "cat.sch.fn" }); }); + it("model_service: ok via `name`", async () => { + const request = vi.fn(async () => new Response("{}")); + const r = await runExistenceProbe( + { request }, + target({ type: "model_service", fieldValues: { name: "cat.sch.llm" } }), + ); + expect(r.status).toBe("ok"); + expect(request).toHaveBeenCalledWith({ + method: "GET", + path: "/api/2.1/unity-catalog/model-services/cat.sch.llm", + }); + }); + + it("model_service: a 404 response is NOT_FOUND", async () => { + const request = vi.fn( + async () => + new Response('{"message":"Model service does not exist"}', { + status: 404, + }), + ); + const r = await runExistenceProbe( + { request }, + target({ type: "model_service", fieldValues: { name: "cat.sch.nope" } }), + ); + expect(r).toMatchObject({ status: "error", code: "NOT_FOUND" }); + }); + it("vector_search_index: probes via camelCase `indexName`", async () => { const getVectorIndex = vi.fn(async () => ({})); const client = { vectorSearch: { getVectorIndex } }; diff --git a/packages/shared/src/cli/commands/doctor/checks-existence.ts b/packages/shared/src/cli/commands/doctor/checks-existence.ts index c9c5bf8c9..fd27de10a 100644 --- a/packages/shared/src/cli/commands/doctor/checks-existence.ts +++ b/packages/shared/src/cli/commands/doctor/checks-existence.ts @@ -38,6 +38,7 @@ interface DoctorWorkspaceClient { functions: { getFunction: (r: { fullNameArg: string }) => Promise; }; + request: (r: { method: string; path: string }) => Promise; } type ExistenceProbe = ( @@ -269,6 +270,24 @@ const probeFunction: ExistenceProbe = async (client, target) => { } }; +// request() resolves on any status; throw non-OK so classifyError reads it. +const probeModelService: ExistenceProbe = async (client, target) => { + const name = field(target, "name"); + if (!name) return missingField("name"); + try { + const res = await client.request({ + method: "GET", + path: `/api/2.1/unity-catalog/model-services/${encodeURIComponent(name)}`, + }); + if (res.ok) return EXISTENCE_OK; + throw Object.assign(new Error(await res.text()), { + statusCode: res.status, + }); + } catch (err) { + return classifyError(err, target); + } +}; + function lakebaseAuthHint(message: string): string | undefined { if (/password authentication failed/i.test(message)) { return ( @@ -343,6 +362,7 @@ const PROBES: Record = { volume: probeVolume, vector_search_index: probeVectorIndex, uc_function: probeFunction, + model_service: probeModelService, postgres: probePostgres, }; diff --git a/packages/shared/src/cli/commands/plugin/create/resource-defaults.ts b/packages/shared/src/cli/commands/plugin/create/resource-defaults.ts index 1e078926b..daf16d0ac 100644 --- a/packages/shared/src/cli/commands/plugin/create/resource-defaults.ts +++ b/packages/shared/src/cli/commands/plugin/create/resource-defaults.ts @@ -53,6 +53,12 @@ export const DEFAULT_FIELDS_BY_TYPE: Record< description: "Serving endpoint ID", }, }, + model_service: { + name: { + env: "DATABRICKS_MODEL_SERVICE_NAME", + description: "Model service full name (catalog.schema.name)", + }, + }, volume: { name: { env: "VOLUME_NAME", description: "Volume name" }, }, diff --git a/packages/shared/src/cli/commands/plugin/schema-resources.ts b/packages/shared/src/cli/commands/plugin/schema-resources.ts index 9df627451..533ff303b 100644 --- a/packages/shared/src/cli/commands/plugin/schema-resources.ts +++ b/packages/shared/src/cli/commands/plugin/schema-resources.ts @@ -12,6 +12,7 @@ import { experimentPermissionSchema, genieSpacePermissionSchema, jobPermissionSchema, + modelServicePermissionSchema, postgresPermissionSchema, resourceTypeSchema, secretPermissionSchema, @@ -51,6 +52,7 @@ const PERMISSION_SCHEMAS_BY_TYPE = { job: jobPermissionSchema, sql_warehouse: sqlWarehousePermissionSchema, serving_endpoint: servingEndpointPermissionSchema, + model_service: modelServicePermissionSchema, volume: volumePermissionSchema, vector_search_index: vectorSearchIndexPermissionSchema, uc_function: ucFunctionPermissionSchema, diff --git a/packages/shared/src/cli/commands/registry/env-writer-picker.test.ts b/packages/shared/src/cli/commands/registry/env-writer-picker.test.ts new file mode 100644 index 000000000..a053070ce --- /dev/null +++ b/packages/shared/src/cli/commands/registry/env-writer-picker.test.ts @@ -0,0 +1,97 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ResourceRequirementRow } from "./requirements"; + +const select = vi.fn(); +vi.mock("@clack/prompts", () => ({ + select: (opts: unknown) => select(opts), + isCancel: () => false, +})); + +const listStep = vi.fn(); +vi.mock("./workspace-picker", async (importOriginal) => ({ + ...(await importOriginal()), + listParentContextStep: (...args: unknown[]) => listStep(...args), +})); + +const { syncEnv } = await import("./env-writer"); + +const MODEL_SERVICE_ROW: ResourceRequirementRow = { + type: "model_service", + required: false, + fields: [ + { key: "name", env: "DATABRICKS_MODEL_SERVICE_NAME", origin: "user" }, + ], +}; + +const LEVELS: Record = { + "": { key: "catalog", values: ["main", "system"] }, + main: { key: "schema", values: ["default"] }, + "system.ai": { key: "model service", values: ["system.ai.gpt-5-mini"] }, + "main.default": { key: "model service", values: ["main.default.chat"] }, +}; + +function stepFor(_type: string, _i: number, parents: string[]) { + const level = LEVELS[parents.join(".")]; + return { + key: level.key, + choices: level.values.map((value) => ({ value, label: value })), + }; +} + +async function pick( + answers: string[], + values?: Record, +): Promise { + select.mockReset(); + for (const a of answers) select.mockResolvedValueOnce(a); + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "env-picker-")); + try { + const [res] = await syncEnv([MODEL_SERVICE_ROW], { + cwd, + nonInteractive: false, + values, + }); + return res?.value; + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } +} + +describe("model_service picker", () => { + beforeEach(() => { + listStep.mockReset(); + listStep.mockImplementation(stepFor); + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + it("opens on system.ai model services", async () => { + expect(await pick(["system.ai.gpt-5-mini"])).toBe("system.ai.gpt-5-mini"); + expect(listStep).toHaveBeenCalledTimes(1); + expect(listStep.mock.calls[0].slice(0, 3)).toEqual([ + "model_service", + 2, + ["system", "ai"], + ]); + const labels = select.mock.calls[0][0].options.map( + (o: { label: string }) => o.label, + ); + expect(labels).toContain("Browse other catalogs..."); + }); + + it("browses into the full catalog/schema drill-down", async () => { + expect( + await pick(["__browse__", "main", "default", "main.default.chat"]), + ).toBe("main.default.chat"); + }); + + it("takes any catalog.schema.name from --set without listing", async () => { + const name = "anthony_ivan.unity_aigw_models.chat"; + expect(await pick([], { DATABRICKS_MODEL_SERVICE_NAME: name })).toBe(name); + expect(listStep).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/shared/src/cli/commands/registry/env-writer.ts b/packages/shared/src/cli/commands/registry/env-writer.ts index 4f06b66e8..4cae6dc26 100644 --- a/packages/shared/src/cli/commands/registry/env-writer.ts +++ b/packages/shared/src/cli/commands/registry/env-writer.ts @@ -16,6 +16,7 @@ import { import type { ResourceRequirementRow } from "./requirements"; import { composeResourceId, + DEFAULT_PARENTS, isFlatListable, isParentContext, listParentContextStep, @@ -36,6 +37,7 @@ export interface EnvSyncOptions { /** Sentinel select value meaning "let me type the id myself". */ const MANUAL = "__manual__"; +const BROWSE = "__browse__"; /** * Max resources shown in a picker select. Real workspaces can have thousands @@ -141,6 +143,29 @@ async function pickParentContext( profile: string | undefined, ): Promise { const depth = parentContextDepth(need.resourceType); + const defaults = DEFAULT_PARENTS[need.resourceType]; + if (defaults) { + // Open on the default parents; an empty listing falls through to the walk. + const step = listParentContextStep( + need.resourceType, + defaults.length, + defaults, + profile, + ); + if (step && step.choices.length > 0) { + const picked = await selectFrom( + `${need.env} — pick a ${step.key} in ${defaults.join(".")}`, + [ + ...capChoices(step.choices, step.key), + { value: BROWSE, label: "Browse other catalogs..." }, + ], + ); + if (picked === null || picked === MANUAL) return undefined; + if (picked !== BROWSE) { + return composeResourceId(need.resourceType, [...defaults, picked]); + } + } + } const picks: string[] = []; for (let i = 0; i < depth; i++) { const step = listParentContextStep(need.resourceType, i, picks, profile); diff --git a/packages/shared/src/cli/commands/registry/workspace-picker.test.ts b/packages/shared/src/cli/commands/registry/workspace-picker.test.ts index b7ad483bf..8e3479f38 100644 --- a/packages/shared/src/cli/commands/registry/workspace-picker.test.ts +++ b/packages/shared/src/cli/commands/registry/workspace-picker.test.ts @@ -365,6 +365,31 @@ describe("listParentContextStep", () => { ]); }); + it("lists model services by bare full name under the picked schema", () => { + const run = vi.fn(() => ({ + status: 0, + stdout: JSON.stringify([{ name: "model-services/system.ai.gpt-5-mini" }]), + })); + const step = listParentContextStep( + "model_service", + 2, + ["system", "ai"], + undefined, + run, + ); + expect(run).toHaveBeenCalledWith([ + "ai-gateway", + "list-model-services", + "--parent", + "schemas/system.ai", + "-o", + "json", + ]); + expect(step?.choices).toEqual([ + { value: "system.ai.gpt-5-mini", label: "system.ai.gpt-5-mini" }, + ]); + }); + it("drills scope → key for secret", () => { const run = vi.fn(() => ({ status: 0, diff --git a/packages/shared/src/cli/commands/registry/workspace-picker.ts b/packages/shared/src/cli/commands/registry/workspace-picker.ts index 1d1a72a89..7e590c741 100644 --- a/packages/shared/src/cli/commands/registry/workspace-picker.ts +++ b/packages/shared/src/cli/commands/registry/workspace-picker.ts @@ -296,6 +296,8 @@ export interface ParentContextStep { command: string[]; idField: string; labelField?: string; + /** Prefix stripped from each listed id (label and value). */ + stripPrefix?: string; }; } @@ -358,6 +360,38 @@ export const PARENT_CONTEXT_CHAINS: Record = { }), }, ], + model_service: [ + { + key: "catalog", + list: () => ({ + command: ["catalogs", "list"], + idField: "name", + labelField: "name", + }), + }, + { + key: "schema", + list: ([catalog]) => ({ + command: ["schemas", "list", catalog], + idField: "name", + labelField: "name", + }), + }, + { + key: "model service", + list: ([catalog, schema]) => ({ + command: [ + "ai-gateway", + "list-model-services", + "--parent", + `schemas/${catalog}.${schema}`, + ], + idField: "name", + // Listed as model-services/{catalog}.{schema}.{name}; apps bind the full name. + stripPrefix: "model-services/", + }), + }, + ], secret: [ { key: "scope", @@ -396,6 +430,11 @@ export const PARENT_CONTEXT_CHAINS: Record = { ], }; +/** Parents a drill-down opens on first; the full walk stays one pick away. */ +export const DEFAULT_PARENTS: Record = { + model_service: ["system", "ai"], +}; + /** True when a resource type needs a parent-context drill-down to list. */ export function isParentContext(resourceType: string): boolean { return resourceType in PARENT_CONTEXT_CHAINS; @@ -429,15 +468,24 @@ export function listParentContextStep( return { key: step.key, choices: [] }; } const spec = step.list(parents); + const choices = runList( + spec.command, + spec.idField, + spec.labelField, + profile, + runner, + ); + const prefix = spec.stripPrefix; return { key: step.key, - choices: runList( - spec.command, - spec.idField, - spec.labelField, - profile, - runner, - ), + choices: prefix + ? choices.map(({ value }) => { + const bare = value.startsWith(prefix) + ? value.slice(prefix.length) + : value; + return { value: bare, label: bare }; + }) + : choices, }; } diff --git a/packages/shared/src/schemas/manifest.test.ts b/packages/shared/src/schemas/manifest.test.ts index b6d9ce9e7..bb243eb7f 100644 --- a/packages/shared/src/schemas/manifest.test.ts +++ b/packages/shared/src/schemas/manifest.test.ts @@ -2,7 +2,9 @@ import { describe, expect, test } from "vitest"; import { APP_ONLY_RESOURCE_TYPES, + DABS_BINDING_BY_TYPE, pluginManifestSchema, + resourceRequirementSchema, SCOPE_BY_TYPE, } from "./manifest"; @@ -13,6 +15,37 @@ const manifest = { resources: { required: [], optional: [] }, }; +describe("model_service resource", () => { + const modelService = { + type: "model_service", + alias: "LLM", + resourceKey: "llm", + description: "Model service", + permission: "EXECUTE", + fields: { name: { env: "DATABRICKS_MODEL_SERVICE_NAME" } }, + }; + + test("accepts EXECUTE and rejects other permissions", () => { + expect(resourceRequirementSchema.safeParse(modelService).success).toBe( + true, + ); + expect( + resourceRequirementSchema.safeParse({ + ...modelService, + permission: "CAN_QUERY", + }).success, + ).toBe(false); + }); + + test("binds as a MODEL_SERVICE uc_securable", () => { + expect(DABS_BINDING_BY_TYPE.model_service).toEqual({ + yamlKey: "uc_securable", + varFields: [["name", "securable_full_name"]], + staticFields: [["securable_type", "MODEL_SERVICE"]], + }); + }); +}); + describe("manifest execution capabilities", () => { test("only maps resource types with confirmed user scopes", () => { expect(SCOPE_BY_TYPE).toEqual({ diff --git a/packages/shared/src/schemas/manifest.ts b/packages/shared/src/schemas/manifest.ts index 1263d4023..e2d656a1e 100644 --- a/packages/shared/src/schemas/manifest.ts +++ b/packages/shared/src/schemas/manifest.ts @@ -40,6 +40,7 @@ export const resourceTypeSchema = z "job", "sql_warehouse", "serving_endpoint", + "model_service", "volume", "vector_search_index", "uc_function", @@ -113,6 +114,11 @@ export const DABS_BINDING_BY_TYPE = { yamlKey: "serving_endpoint", varFields: [["name", "name"]], }, + model_service: { + yamlKey: "uc_securable", + varFields: [["name", "securable_full_name"]], + staticFields: [["securable_type", "MODEL_SERVICE"]], + }, experiment: { yamlKey: "experiment", varFields: [["experimentId", "experiment_id"]], @@ -222,6 +228,10 @@ export const volumePermissionSchema = z .enum(["READ_VOLUME", "WRITE_VOLUME"]) .describe("Permission for Unity Catalog volume resources"); +export const modelServicePermissionSchema = z + .enum(["EXECUTE"]) + .describe("Permission for Unity Catalog model service resources"); + export const vectorSearchIndexPermissionSchema = z .enum(["SELECT"]) .describe("Permission for vector search index resources"); @@ -659,6 +669,10 @@ export const resourceRequirementSchema = z z.literal("serving_endpoint"), servingEndpointPermissionSchema, ), + makeResourceVariant( + z.literal("model_service"), + modelServicePermissionSchema, + ), makeResourceVariant(z.literal("volume"), volumePermissionSchema), makeResourceVariant( z.literal("vector_search_index"), @@ -1061,6 +1075,10 @@ export const templateResourceRequirementSchema = z z.literal("serving_endpoint"), servingEndpointPermissionSchema, ), + makeTemplateResourceVariant( + z.literal("model_service"), + modelServicePermissionSchema, + ), makeTemplateResourceVariant(z.literal("volume"), volumePermissionSchema), makeTemplateResourceVariant( z.literal("vector_search_index"), @@ -1368,6 +1386,9 @@ export type SqlWarehousePermission = z.infer< export type ServingEndpointPermission = z.infer< typeof servingEndpointPermissionSchema >; +export type ModelServicePermission = z.infer< + typeof modelServicePermissionSchema +>; export type VolumePermission = z.infer; export type VectorSearchIndexPermission = z.infer< typeof vectorSearchIndexPermissionSchema diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json index db53b96b7..fa2aeb01b 100644 --- a/template/appkit.plugins.json +++ b/template/appkit.plugins.json @@ -11,25 +11,30 @@ "required": [], "optional": [ { - "type": "serving_endpoint", + "type": "model_service", "alias": "Default LLM for agents", - "resourceKey": "agents-serving-endpoint", - "description": "Default streaming-capable LLM endpoint for agents that don't pin their own model", - "permission": "CAN_QUERY", + "resourceKey": "agents-model-service", + "description": "Default Unity Catalog model service, called through AI Gateway, for agents that don't pin their own model", + "permission": "EXECUTE", "fields": { "name": { - "env": "DATABRICKS_SERVING_ENDPOINT_NAME", - "description": "Default LLM serving endpoint name", + "env": "DATABRICKS_MODEL_SERVICE_NAME", + "description": "Default model service full name (catalog.schema.name)", "origin": "user" } }, - "scope": "model-serving", "binding": { - "yamlKey": "serving_endpoint", + "yamlKey": "uc_securable", "varFields": [ [ "name", - "name" + "securable_full_name" + ] + ], + "staticFields": [ + [ + "securable_type", + "MODEL_SERVICE" ] ] } diff --git a/tools/generate-registry-types.ts b/tools/generate-registry-types.ts index 08709839c..12aa6b6d5 100644 --- a/tools/generate-registry-types.ts +++ b/tools/generate-registry-types.ts @@ -18,6 +18,7 @@ import { experimentPermissionSchema, genieSpacePermissionSchema, jobPermissionSchema, + modelServicePermissionSchema, postgresPermissionSchema, resourceTypeSchema, secretPermissionSchema, @@ -62,6 +63,7 @@ const PERMISSION_SCHEMAS_BY_TYPE = { job: jobPermissionSchema, sql_warehouse: sqlWarehousePermissionSchema, serving_endpoint: servingEndpointPermissionSchema, + model_service: modelServicePermissionSchema, volume: volumePermissionSchema, vector_search_index: vectorSearchIndexPermissionSchema, uc_function: ucFunctionPermissionSchema,