diff --git a/docs/rest-apis/gateway/README.md b/docs/rest-apis/gateway/README.md index 9c673a12b..f29bc3d31 100644 --- a/docs/rest-apis/gateway/README.md +++ b/docs/rest-apis/gateway/README.md @@ -98,34 +98,5 @@ Base URLs: - [Update a secret](secrets-management.md#update-a-secret) - [Delete a secret](secrets-management.md#delete-a-secret) -### [WebSub API Management](websub-api-management.md) - -- [Create a new WebSubAPI](websub-api-management.md#create-a-new-websubapi) -- [List all WebSubAPIs](websub-api-management.md#list-all-websubapis) -- [Create a new API key for a WebSub API](websub-api-management.md#create-a-new-api-key-for-a-websub-api) -- [Get the list of API keys for a WebSub API](websub-api-management.md#get-the-list-of-api-keys-for-a-websub-api) -- [Regenerate API key for a WebSub API](websub-api-management.md#regenerate-api-key-for-a-websub-api) -- [Update an API key for a WebSub API](websub-api-management.md#update-an-api-key-for-a-websub-api) -- [Revoke an API key for a WebSub API](websub-api-management.md#revoke-an-api-key-for-a-websub-api) -- [Generate a new HMAC secret for a WebSub API](websub-api-management.md#generate-a-new-hmac-secret-for-a-websub-api) -- [List HMAC secrets for a WebSub API](websub-api-management.md#list-hmac-secrets-for-a-websub-api) -- [Regenerate (rotate) a WebSub API HMAC secret](websub-api-management.md#regenerate-rotate-a-websub-api-hmac-secret) -- [Delete a WebSub API HMAC secret](websub-api-management.md#delete-a-websub-api-hmac-secret) -- [Get WebSubAPI by id](websub-api-management.md#get-websubapi-by-id) -- [Update an existing WebSubAPI](websub-api-management.md#update-an-existing-websubapi) -- [Delete a WebSubAPI](websub-api-management.md#delete-a-websubapi) - -### [WebBroker API Management](webbroker-api-management.md) - -- [Create a new WebBrokerAPI](webbroker-api-management.md#create-a-new-webbrokerapi) -- [List all WebBrokerAPIs](webbroker-api-management.md#list-all-webbrokerapis) -- [Get WebBrokerAPI by id](webbroker-api-management.md#get-webbrokerapi-by-id) -- [Delete a WebBrokerAPI](webbroker-api-management.md#delete-a-webbrokerapi) -- [Create a new API key for a WebBroker API](webbroker-api-management.md#create-a-new-api-key-for-a-webbroker-api) -- [Get the list of API keys for a WebBroker API](webbroker-api-management.md#get-the-list-of-api-keys-for-a-webbroker-api) -- [Regenerate API key for a WebBroker API](webbroker-api-management.md#regenerate-api-key-for-a-webbroker-api) -- [Update an API key for a WebBroker API](webbroker-api-management.md#update-an-api-key-for-a-webbroker-api) -- [Revoke an API key for a WebBroker API](webbroker-api-management.md#revoke-an-api-key-for-a-webbroker-api) - ### [Schemas](schemas.md) diff --git a/docs/rest-apis/gateway/llm-provider-management.md b/docs/rest-apis/gateway/llm-provider-management.md index 45d90122b..5d6ffbc4e 100644 --- a/docs/rest-apis/gateway/llm-provider-management.md +++ b/docs/rest-apis/gateway/llm-provider-management.md @@ -297,6 +297,14 @@ Status Code **200** |»»»» context|string|false|none|Base path for all API routes (must start with /, no trailing slash)| |»»»» vhost|string|false|none|Virtual host name used for routing. Supports standard domain names, subdomains, or wildcard domains. Must follow RFC-compliant hostname rules. Wildcards are only allowed in the left-most label (e.g., *.example.com).| |»»»» template|string|true|none|Template name to use for this LLM Provider| +|»»»» upstreamDefinitions|[[UpstreamDefinition](schemas.md#schemaupstreamdefinition)]|false|none|List of reusable upstream definitions with optional timeout configurations. Referenced by upstream.ref.| +|»»»»» name|string|true|none|Unique identifier for this upstream definition| +|»»»»» basePath|string|false|none|Base path prefix for all endpoints in this upstream (e.g., /api/v2). All requests to this upstream will have this path prepended. Must start with '/' and must not end with '/'; omit for root.| +|»»»»» timeout|[UpstreamTimeout](schemas.md#schemaupstreamtimeout)|false|none|Timeout configuration for upstream requests| +|»»»»»» connect|string|false|none|Connection timeout duration (e.g., "5s", "500ms")| +|»»»»» upstreams|[object]|true|none|List of backend targets with optional weights for load balancing| +|»»»»»» url|string(uri)|true|none|Backend URL (host and port only, path comes from basePath)| +|»»»»»» weight|integer|false|none|Relative weight for load balancing across multiple upstream targets. Reserved for future multi-target load balancing; not applied yet (only the first target is currently used).| |»»»» upstream|any|true|none|none| *allOf* @@ -360,6 +368,9 @@ Status Code **200** |»»»»»» methods|[string]|true|none|none| |»»»»»» params|object|true|none|JSON Schema describing the parameters accepted by this policy. This itself is a JSON Schema document.| |»»»» deploymentState|string|false|none|Desired deployment state - 'deployed' (default) or 'undeployed'. When set to 'undeployed', the LLM Provider is removed from router traffic but configuration and policies are preserved for potential redeployment.| +|»»»» resilience|[Resilience](schemas.md#schemaresilience)|false|none|Backend/route timeout configuration. Maps to Envoy RouteAction timeouts. Can be set at the API level (applies to all routes) and/or the operation level (applies to that operation's route). When set at both levels, the operation-level value takes precedence. When unset, the gateway's global route timeout defaults apply.| +|»»»»» timeout|string|false|none|Maximum time for the entire route (request to upstream response). "0s" disables the timeout.| +|»»»»» idleTimeout|string|false|none|Per-route stream idle timeout (overrides the listener stream idle timeout for this route). "0s" disables the timeout.| *and* @@ -382,6 +393,8 @@ Status Code **200** |hostRewrite|auto| |hostRewrite|manual| |type|api-key| +|type|other| +|type|none| |mode|allow_all| |mode|deny_all| |deploymentState|deployed| diff --git a/docs/rest-apis/gateway/llm-proxy-management.md b/docs/rest-apis/gateway/llm-proxy-management.md index a3080695f..8e1cd39bf 100644 --- a/docs/rest-apis/gateway/llm-proxy-management.md +++ b/docs/rest-apis/gateway/llm-proxy-management.md @@ -231,6 +231,14 @@ Status Code **200** |»»»»»» path|string|true|none|none| |»»»»»» methods|[string]|true|none|none| |»»»»»» params|object|true|none|JSON Schema describing the parameters accepted by this policy. This itself is a JSON Schema document.| +|»»»» additionalProviders|[[LLMProxyAdditionalProvider](schemas.md#schemallmproxyadditionalprovider)]|false|none|Optional list of additional LLM providers attached to this proxy as selectable upstreams. Policies (e.g. an OpenAI translator) can route requests to any of these by setting the upstream name. The primary `provider` field above remains the default upstream and the FK target.| +|»»»»» id|string|true|none|Unique id of a deployed llm provider| +|»»»»» as|string|false|none|Logical LLM Provider name used by policies to select this provider. Must be unique within the proxy. Defaults to `id` when omitted.| +|»»»»» auth|[LLMUpstreamAuth](schemas.md#schemallmupstreamauth)|false|none|none| +|»»»»» transformer|[LLMProxyTransformer](schemas.md#schemallmproxytransformer)|false|none|Request/response translator applied when this provider is the selected upstream. The proxy injects the translator as a conditional policy whose execution condition matches this provider, so it runs only when the provider is selected. The provider's `as` name (defaults to `id`) is passed to the translator as its target upstream.| +|»»»»»» type|string|true|none|Translator policy name (for example openai-to-anthropic).| +|»»»»»» version|string|true|none|Major-only translator policy version (for example v1). The Gateway Controller resolves it to the installed full version.| +|»»»»»» params|object|false|none|Translator-specific parameters (for example model, apiVersion).| |»»»» policies|[[LLMPolicy](schemas.md#schemallmpolicy)]|false|none|DEPRECATED - use operationPolicies. Still honoured (treated identically to operationPolicies).| |»»»»» name|string|true|none|none| |»»»»» version|string|true|none|none| @@ -239,6 +247,9 @@ Status Code **200** |»»»»»» methods|[string]|true|none|none| |»»»»»» params|object|true|none|JSON Schema describing the parameters accepted by this policy. This itself is a JSON Schema document.| |»»»» deploymentState|string|false|none|Desired deployment state - 'deployed' (default) or 'undeployed'. When set to 'undeployed', the LLM Proxy is removed from router traffic but configuration and policies are preserved for potential redeployment.| +|»»»» resilience|[Resilience](schemas.md#schemaresilience)|false|none|Backend/route timeout configuration. Maps to Envoy RouteAction timeouts. Can be set at the API level (applies to all routes) and/or the operation level (applies to that operation's route). When set at both levels, the operation-level value takes precedence. When unset, the gateway's global route timeout defaults apply.| +|»»»»» timeout|string|false|none|Maximum time for the entire route (request to upstream response). "0s" disables the timeout.| +|»»»»» idleTimeout|string|false|none|Per-route stream idle timeout (overrides the listener stream idle timeout for this route). "0s" disables the timeout.| *and* @@ -259,6 +270,8 @@ Status Code **200** |apiVersion|gateway.api-platform.wso2.com/v1| |kind|LlmProxy| |type|api-key| +|type|other| +|type|none| |deploymentState|deployed| |deploymentState|undeployed| |state|deployed| diff --git a/docs/rest-apis/gateway/mcp-proxy-management.md b/docs/rest-apis/gateway/mcp-proxy-management.md index 492e78b5b..b2fdbc294 100644 --- a/docs/rest-apis/gateway/mcp-proxy-management.md +++ b/docs/rest-apis/gateway/mcp-proxy-management.md @@ -221,6 +221,14 @@ Status Code **200** |»»»» context|string|false|none|MCP Proxy context path| |»»»» specVersion|string|false|none|MCP specification version| |»»»» vhost|string|false|none|Virtual host name used for routing. Supports standard domain names, subdomains, or wildcard domains. Must follow RFC-compliant hostname rules. Wildcards are only allowed in the left-most label (e.g., *.example.com).| +|»»»» upstreamDefinitions|[[UpstreamDefinition](schemas.md#schemaupstreamdefinition)]|false|none|List of reusable upstream definitions with optional timeout configurations. Referenced by upstream.ref.| +|»»»»» name|string|true|none|Unique identifier for this upstream definition| +|»»»»» basePath|string|false|none|Base path prefix for all endpoints in this upstream (e.g., /api/v2). All requests to this upstream will have this path prepended. Must start with '/' and must not end with '/'; omit for root.| +|»»»»» timeout|[UpstreamTimeout](schemas.md#schemaupstreamtimeout)|false|none|Timeout configuration for upstream requests| +|»»»»»» connect|string|false|none|Connection timeout duration (e.g., "5s", "500ms")| +|»»»»» upstreams|[object]|true|none|List of backend targets with optional weights for load balancing| +|»»»»»» url|string(uri)|true|none|Backend URL (host and port only, path comes from basePath)| +|»»»»»» weight|integer|false|none|Relative weight for load balancing across multiple upstream targets. Reserved for future multi-target load balancing; not applied yet (only the first target is currently used).| |»»»» upstream|any|true|none|The backend MCP server url and auth configurations| *allOf* @@ -286,6 +294,9 @@ Status Code **200** |»»»»»» required|boolean|false|none|Whether the argument is required| |»»»»»» title|string|false|none|Optional human-readable title of the argument| |»»»» deploymentState|string|false|none|Desired deployment state - 'deployed' (default) or 'undeployed'. When set to 'undeployed', the MCP Proxy is removed from router traffic but configuration and policies are preserved for potential redeployment.| +|»»»» resilience|[Resilience](schemas.md#schemaresilience)|false|none|Backend/route timeout configuration. Maps to Envoy RouteAction timeouts. Can be set at the API level (applies to all routes) and/or the operation level (applies to that operation's route). When set at both levels, the operation-level value takes precedence. When unset, the gateway's global route timeout defaults apply.| +|»»»»» timeout|string|false|none|Maximum time for the entire route (request to upstream response). "0s" disables the timeout.| +|»»»»» idleTimeout|string|false|none|Per-route stream idle timeout (overrides the listener stream idle timeout for this route). "0s" disables the timeout.| *and* @@ -308,6 +319,8 @@ Status Code **200** |hostRewrite|auto| |hostRewrite|manual| |type|api-key| +|type|other| +|type|none| |deploymentState|deployed| |deploymentState|undeployed| |state|deployed| diff --git a/docs/rest-apis/gateway/rest-api-management.md b/docs/rest-apis/gateway/rest-api-management.md index 87b6b3a87..e0415c8ee 100644 --- a/docs/rest-apis/gateway/rest-api-management.md +++ b/docs/rest-apis/gateway/rest-api-management.md @@ -353,12 +353,12 @@ Status Code **200** |»»»» context|string|true|none|Base path for all API routes (must start with /, no trailing slash). Use $version to embed the version in the path (e.g., /reading-list/$version resolves to /reading-list/v1.0).| |»»»» upstreamDefinitions|[[UpstreamDefinition](schemas.md#schemaupstreamdefinition)]|false|none|List of reusable upstream definitions with optional timeout configurations| |»»»»» name|string|true|none|Unique identifier for this upstream definition| -|»»»»» basePath|string|false|none|Base path prefix for all endpoints in this upstream (e.g., /api/v2). All requests to this upstream will have this path prepended.| +|»»»»» basePath|string|false|none|Base path prefix for all endpoints in this upstream (e.g., /api/v2). All requests to this upstream will have this path prepended. Must start with '/' and must not end with '/'; omit for root.| |»»»»» timeout|[UpstreamTimeout](schemas.md#schemaupstreamtimeout)|false|none|Timeout configuration for upstream requests| |»»»»»» connect|string|false|none|Connection timeout duration (e.g., "5s", "500ms")| |»»»»» upstreams|[object]|true|none|List of backend targets with optional weights for load balancing| |»»»»»» url|string(uri)|true|none|Backend URL (host and port only, path comes from basePath)| -|»»»»»» weight|integer|false|none|Weight for load balancing (optional, default 100)| +|»»»»»» weight|integer|false|none|Relative weight for load balancing across multiple upstream targets. Reserved for future multi-target load balancing; not applied yet (only the first target is currently used).| |»»»» upstream|object|true|none|API-level upstream configuration| |»»»»» main|[Upstream](schemas.md#schemaupstream)|true|none|Upstream backend configuration (single target or reference)| |»»»»»» url|string(uri)|false|none|Direct backend URL to route traffic to| @@ -383,7 +383,7 @@ Status Code **200** |---|---|---|---|---| |»»»»» sandbox|[Upstream](schemas.md#schemaupstream)|false|none|Upstream backend configuration (single target or reference)| |»»»» vhosts|object|false|none|Custom virtual hosts/domains for the API| -|»»»»» main|string|true|none|Custom virtual host/domain for production traffic| +|»»»»» main|string|true|none|Custom virtual host(s)/domain(s) for production traffic. One or more hostnames
separated by ';' — each hostname serves the main upstream (e.g. when a Gateway API
HTTPRoute attaches to multiple listener hostnames). The first entry is the primary
vhost. Each hostname may be a wildcard such as *.example.com.| |»»»»» sandbox|string|false|none|Custom virtual host/domain for sandbox traffic| |»»»» subscriptionPlans|[string]|false|none|List of subscription plan names available for this API| |»»»» policies|[[Policy](schemas.md#schemapolicy)]|false|none|List of API-level policies applied to all operations unless overridden| @@ -391,10 +391,23 @@ Status Code **200** |»»»»» version|string|true|none|Version of the policy. Only major-only version is allowed (e.g., v0, v1). Full semantic version (e.g., v1.0.0) is not accepted and will be rejected. The Gateway Controller resolves the major version to the single matching full version installed in the gateway image.| |»»»»» executionCondition|string|false|none|Expression controlling conditional execution of the policy| |»»»»» params|object|false|none|Arbitrary parameters for the policy (free-form key/value structure)| +|»»»» resilience|[Resilience](schemas.md#schemaresilience)|false|none|Backend/route timeout configuration. Maps to Envoy RouteAction timeouts. Can be set at the API level (applies to all routes) and/or the operation level (applies to that operation's route). When set at both levels, the operation-level value takes precedence. When unset, the gateway's global route timeout defaults apply.| +|»»»»» timeout|string|false|none|Maximum time for the entire route (request to upstream response). "0s" disables the timeout.| +|»»»»» idleTimeout|string|false|none|Per-route stream idle timeout (overrides the listener stream idle timeout for this route). "0s" disables the timeout.| |»»»» operations|[[Operation](schemas.md#schemaoperation)]|true|none|List of HTTP operations/routes| -|»»»»» method|string|true|none|HTTP method| -|»»»»» path|string|true|none|Route path with optional {param} placeholders| +|»»»»» method|[OperationMethod](schemas.md#schemaoperationmethod)|false|none|HTTP method (simple form; ignored when 'match' is set)| +|»»»»» path|string|false|none|Route path with optional {param} placeholders (simple form; ignored when 'match' is set)| +|»»»»» match|[OperationMatch](schemas.md#schemaoperationmatch)|false|none|Request matching criteria for an operation. Extensible with query params, cookies, etc.| +|»»»»»» method|[OperationMethod](schemas.md#schemaoperationmethod)|true|none|HTTP method| +|»»»»»» path|[OperationPathMatch](schemas.md#schemaoperationpathmatch)|true|none|none| +|»»»»»»» value|string|true|none|Route path with optional {param} placeholders| +|»»»»»»» type|string|false|none|Path matching semantics for the operation route| +|»»»»»» headers|[[OperationHeaderMatch](schemas.md#schemaoperationheadermatch)]|false|none|Header matchers ANDed with the path match for Envoy route selection| +|»»»»»»» name|string|true|none|Header name (case-insensitive)| +|»»»»»»» value|string|true|none|Header value to match| +|»»»»»»» type|string|false|none|Header match type| |»»»»» policies|[[Policy](schemas.md#schemapolicy)]|false|none|List of policies applied only to this operation (overrides or adds to API-level policies)| +|»»»»» resilience|[Resilience](schemas.md#schemaresilience)|false|none|Backend/route timeout configuration. Maps to Envoy RouteAction timeouts. Can be set at the API level (applies to all routes) and/or the operation level (applies to that operation's route). When set at both levels, the operation-level value takes precedence. When unset, the gateway's global route timeout defaults apply.| |»»»» deploymentState|string|false|none|Desired deployment state - 'deployed' (default) or 'undeployed'. When set to 'undeployed', the API is removed from router traffic but configuration, API keys, and policies are preserved for potential redeployment.| *and* @@ -424,6 +437,17 @@ Status Code **200** |method|PATCH| |method|HEAD| |method|OPTIONS| +|method|GET| +|method|POST| +|method|PUT| +|method|DELETE| +|method|PATCH| +|method|HEAD| +|method|OPTIONS| +|type|Exact| +|type|PathPrefix| +|type|Exact| +|type|RegularExpression| |deploymentState|deployed| |deploymentState|undeployed| |state|deployed| diff --git a/docs/rest-apis/gateway/schemas.md b/docs/rest-apis/gateway/schemas.md index 52b214505..343d6cbe3 100644 --- a/docs/rest-apis/gateway/schemas.md +++ b/docs/rest-apis/gateway/schemas.md @@ -223,110 +223,6 @@ and |*anonymous*|object|false|none|none| |» status|[ResourceStatus](#schemaresourcestatus)|false|read-only|Server-managed lifecycle fields. Populated on responses.| -

WebSubAPIRequest

- - - - - - -```json -{ - "apiVersion": "gateway.api-platform.wso2.com/v1", - "kind": "WebSubApi", - "metadata": { - "name": "github-events-v1.0" - }, - "spec": { - "displayName": "GitHub Events", - "version": "v1.0", - "context": "/github-events/$version", - "channels": [ - { - "name": "issues", - "method": "SUB" - }, - { - "name": "pull_requests", - "method": "SUB" - } - ] - } -} - -``` - -### Properties - -|Name|Type|Required|Restrictions|Description| -|---|---|---|---|---| -|apiVersion|string|true|none|API specification version| -|kind|string|true|none|API type| -|metadata|[Metadata](#schemametadata)|true|none|none| -|spec|[WebhookAPIData](#schemawebhookapidata)|true|none|none| - -#### Enumerated Values - -|Property|Value| -|---|---| -|apiVersion|gateway.api-platform.wso2.com/v1| -|kind|WebSubApi| - -

WebSubAPI

- - - - - - -```json -{ - "apiVersion": "gateway.api-platform.wso2.com/v1", - "kind": "WebSubApi", - "metadata": { - "name": "github-events-v1.0" - }, - "spec": { - "displayName": "GitHub Events", - "version": "v1.0", - "context": "/github-events/$version", - "channels": [ - { - "name": "issues", - "method": "SUB" - }, - { - "name": "pull_requests", - "method": "SUB" - } - ] - }, - "status": { - "id": "github-events-v1.0", - "state": "deployed", - "createdAt": "2026-04-24T07:21:13Z", - "updatedAt": "2026-04-24T07:21:13Z", - "deployedAt": "2026-04-24T07:21:13Z" - } -} - -``` - -### Properties - -allOf - -|Name|Type|Required|Restrictions|Description| -|---|---|---|---|---| -|*anonymous*|[WebSubAPIRequest](#schemawebsubapirequest)|false|none|none| - -and - -|Name|Type|Required|Restrictions|Description| -|---|---|---|---|---| -|*anonymous*|object|false|none|none| -|» status|[ResourceStatus](#schemaresourcestatus)|false|read-only|Server-managed lifecycle fields. Populated on responses.| -

Metadata

@@ -399,7 +295,7 @@ and } }, "vhosts": { - "main": "api.example.com", + "main": "api.example.com;docs.example.com;*.example.com", "sandbox": "sandbox-api.example.com" }, "subscriptionPlans": [ @@ -414,10 +310,28 @@ and "params": {} } ], + "resilience": { + "timeout": "15s", + "idleTimeout": "0s" + }, "operations": [ { "method": "GET", "path": "/books/{id}", + "match": { + "method": "GET", + "path": { + "value": "/books/{id}", + "type": "Exact" + }, + "headers": [ + { + "name": "version", + "value": "one", + "type": "Exact" + } + ] + }, "policies": [ { "name": "cors", @@ -425,7 +339,11 @@ and "executionCondition": "request.metadata[authenticated] != true", "params": {} } - ] + ], + "resilience": { + "timeout": "15s", + "idleTimeout": "0s" + } } ], "deploymentState": "deployed" @@ -445,10 +363,11 @@ and |» main|[Upstream](#schemaupstream)|true|none|Upstream backend configuration (single target or reference)| |» sandbox|[Upstream](#schemaupstream)|false|none|Upstream backend configuration (single target or reference)| |vhosts|object|false|none|Custom virtual hosts/domains for the API| -|» main|string|true|none|Custom virtual host/domain for production traffic| +|» main|string|true|none|Custom virtual host(s)/domain(s) for production traffic. One or more hostnames
separated by ';' — each hostname serves the main upstream (e.g. when a Gateway API
HTTPRoute attaches to multiple listener hostnames). The first entry is the primary
vhost. Each hostname may be a wildcard such as *.example.com.| |» sandbox|string|false|none|Custom virtual host/domain for sandbox traffic| |subscriptionPlans|[string]|false|none|List of subscription plan names available for this API| |policies|[[Policy](#schemapolicy)]|false|none|List of API-level policies applied to all operations unless overridden| +|resilience|[Resilience](#schemaresilience)|false|none|Backend/route timeout configuration. Maps to Envoy RouteAction timeouts. Can be set at the API level (applies to all routes) and/or the operation level (applies to that operation's route). When set at both levels, the operation-level value takes precedence. When unset, the gateway's global route timeout defaults apply.| |operations|[[Operation](#schemaoperation)]|true|none|List of HTTP operations/routes| |deploymentState|string|false|none|Desired deployment state - 'deployed' (default) or 'undeployed'. When set to 'undeployed', the API is removed from router traffic but configuration, API keys, and policies are preserved for potential redeployment.| @@ -490,11 +409,11 @@ Reusable upstream configuration with optional timeout and load balancing setting |Name|Type|Required|Restrictions|Description| |---|---|---|---|---| |name|string|true|none|Unique identifier for this upstream definition| -|basePath|string|false|none|Base path prefix for all endpoints in this upstream (e.g., /api/v2). All requests to this upstream will have this path prepended.| +|basePath|string|false|none|Base path prefix for all endpoints in this upstream (e.g., /api/v2). All requests to this upstream will have this path prepended. Must start with '/' and must not end with '/'; omit for root.| |timeout|[UpstreamTimeout](#schemaupstreamtimeout)|false|none|Timeout configuration for upstream requests| |upstreams|[object]|true|none|List of backend targets with optional weights for load balancing| |» url|string(uri)|true|none|Backend URL (host and port only, path comes from basePath)| -|» weight|integer|false|none|Weight for load balancing (optional, default 100)| +|» weight|integer|false|none|Relative weight for load balancing across multiple upstream targets. Reserved for future multi-target load balancing; not applied yet (only the first target is currently used).|

UpstreamTimeout

@@ -518,6 +437,30 @@ Timeout configuration for upstream requests |---|---|---|---|---| |connect|string|false|none|Connection timeout duration (e.g., "5s", "500ms")| +

Resilience

+ + + + + + +```json +{ + "timeout": "15s", + "idleTimeout": "0s" +} + +``` + +Backend/route timeout configuration. Maps to Envoy RouteAction timeouts. Can be set at the API level (applies to all routes) and/or the operation level (applies to that operation's route). When set at both levels, the operation-level value takes precedence. When unset, the gateway's global route timeout defaults apply. + +### Properties + +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|timeout|string|false|none|Maximum time for the entire route (request to upstream response). "0s" disables the timeout.| +|idleTimeout|string|false|none|Per-route stream idle timeout (overrides the listener stream idle timeout for this route). "0s" disables the timeout.| +

Upstream

@@ -574,6 +517,20 @@ xor { "method": "GET", "path": "/books/{id}", + "match": { + "method": "GET", + "path": { + "value": "/books/{id}", + "type": "Exact" + }, + "headers": [ + { + "name": "version", + "value": "one", + "type": "Exact" + } + ] + }, "policies": [ { "name": "cors", @@ -581,1065 +538,218 @@ xor "executionCondition": "request.metadata[authenticated] != true", "params": {} } - ] -} - -``` - -### Properties - -|Name|Type|Required|Restrictions|Description| -|---|---|---|---|---| -|method|string|true|none|HTTP method| -|path|string|true|none|Route path with optional {param} placeholders| -|policies|[[Policy](#schemapolicy)]|false|none|List of policies applied only to this operation (overrides or adds to API-level policies)| - -#### Enumerated Values - -|Property|Value| -|---|---| -|method|GET| -|method|POST| -|method|PUT| -|method|DELETE| -|method|PATCH| -|method|HEAD| -|method|OPTIONS| - -

Policy

- - - - - - -```json -{ - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} + ], + "resilience": { + "timeout": "15s", + "idleTimeout": "0s" + } } ``` -### Properties - -|Name|Type|Required|Restrictions|Description| -|---|---|---|---|---| -|name|string|true|none|Name of the policy| -|version|string|true|none|Version of the policy. Only major-only version is allowed (e.g., v0, v1). Full semantic version (e.g., v1.0.0) is not accepted and will be rejected. The Gateway Controller resolves the major version to the single matching full version installed in the gateway image.| -|executionCondition|string|false|none|Expression controlling conditional execution of the policy| -|params|object|false|none|Arbitrary parameters for the policy (free-form key/value structure)| - -

WebhookAPIData

- - - - - - -```json -{ - "displayName": "reading-list-api", - "version": "v1.0", - "context": "/weather", - "vhosts": { - "main": "api.example.com", - "sandbox": "sandbox-api.example.com" - }, - "allChannels": { - "on_subscription": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - }, - "on_unsubscription": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - }, - "on_message_received": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - }, - "on_message_delivery": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - } - }, - "channels": { - "property1": { - "on_subscription": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - }, - "on_unsubscription": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - }, - "on_message_received": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - }, - "on_message_delivery": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - } - }, - "property2": { - "on_subscription": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - }, - "on_unsubscription": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - }, - "on_message_received": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - }, - "on_message_delivery": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - } - } - }, - "deploymentState": "deployed" -} - -``` +An operation is matched either by the simple top-level method+path form, or by the richer 'match' block (method + path + headers). When 'match' is present it is authoritative and the top-level method/path are ignored. At least one form must be provided. ### Properties |Name|Type|Required|Restrictions|Description| |---|---|---|---|---| -|displayName|string|true|none|Human-readable API name (must be URL-friendly - only letters, numbers, spaces, hyphens, underscores, and dots allowed)| -|version|string|true|none|Semantic version of the API| -|context|string|true|none|Base path for all API routes (must start with /, no trailing slash)| -|vhosts|object|false|none|Custom virtual hosts/domains for the API| -|» main|string|true|none|Custom virtual host/domain for production traffic| -|» sandbox|string|false|none|Custom virtual host/domain for sandbox traffic| -|allChannels|[WebSubAllChannelPolicies](#schemawebsuballchannelpolicies)|false|none|Policies applied to all channels, organized by event type.| -|channels|object|false|none|Per-channel configuration keyed by channel name. Each key is a channel name and defines policies applied only to that channel.| -|» **additionalProperties**|[WebSubChannel](#schemawebsubchannel)|false|none|A single channel definition with optional per-channel policy overrides.| -|deploymentState|string|false|none|Desired deployment state - 'deployed' (default) or 'undeployed'. When set to 'undeployed', the API is removed from router traffic but configuration, API keys, and policies are preserved for potential redeployment.| - -#### Enumerated Values - -|Property|Value| -|---|---| -|deploymentState|deployed| -|deploymentState|undeployed| +|method|[OperationMethod](#schemaoperationmethod)|false|none|HTTP method (simple form; ignored when 'match' is set)| +|path|string|false|none|Route path with optional {param} placeholders (simple form; ignored when 'match' is set)| +|match|[OperationMatch](#schemaoperationmatch)|false|none|Request matching criteria for an operation. Extensible with query params, cookies, etc.| +|policies|[[Policy](#schemapolicy)]|false|none|List of policies applied only to this operation (overrides or adds to API-level policies)| +|resilience|[Resilience](#schemaresilience)|false|none|Backend/route timeout configuration. Maps to Envoy RouteAction timeouts. Can be set at the API level (applies to all routes) and/or the operation level (applies to that operation's route). When set at both levels, the operation-level value takes precedence. When unset, the gateway's global route timeout defaults apply.| -

WebSubChannel

+

OperationMethod

- - - - + + + + -```json -{ - "on_subscription": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - }, - "on_unsubscription": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - }, - "on_message_received": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - }, - "on_message_delivery": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - } -} - -``` - -A single channel definition with optional per-channel policy overrides. - -### Properties - -|Name|Type|Required|Restrictions|Description| -|---|---|---|---|---| -|on_subscription|[WebSubEventPolicies](#schemawebsubeventpolicies)|false|none|Policies for a single event type.| -|on_unsubscription|[WebSubEventPolicies](#schemawebsubeventpolicies)|false|none|Policies for a single event type.| -|on_message_received|[WebSubEventPolicies](#schemawebsubeventpolicies)|false|none|Policies for a single event type.| -|on_message_delivery|[WebSubEventPolicies](#schemawebsubeventpolicies)|false|none|Policies for a single event type.| - -

WebSubEventPolicies

- - - - - - -```json -{ - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] -} - -``` - -Policies for a single event type. - -### Properties - -|Name|Type|Required|Restrictions|Description| -|---|---|---|---|---| -|policies|[[Policy](#schemapolicy)]|false|none|List of policies applied for this event type.| - -

WebSubAllChannelPolicies

- - - - - - -```json -{ - "on_subscription": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - }, - "on_unsubscription": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - }, - "on_message_received": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - }, - "on_message_delivery": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - } -} - -``` - -Policies applied to all channels, organized by event type. - -### Properties - -|Name|Type|Required|Restrictions|Description| -|---|---|---|---|---| -|on_subscription|[WebSubEventPolicies](#schemawebsubeventpolicies)|false|none|Policies for a single event type.| -|on_unsubscription|[WebSubEventPolicies](#schemawebsubeventpolicies)|false|none|Policies for a single event type.| -|on_message_received|[WebSubEventPolicies](#schemawebsubeventpolicies)|false|none|Policies for a single event type.| -|on_message_delivery|[WebSubEventPolicies](#schemawebsubeventpolicies)|false|none|Policies for a single event type.| - -

WebSubChannelPolicies

- - - - - - -```json -{ - "on_subscription": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ], - "on_unsubscription": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ], - "on_message_received": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ], - "on_message_delivery": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] -} - -``` - -Policies applied to a specific channel, organized by event type. - -### Properties - -|Name|Type|Required|Restrictions|Description| -|---|---|---|---|---| -|on_subscription|[[Policy](#schemapolicy)]|false|none|Policies applied when a client subscribes to this channel (e.g., rbac)| -|on_unsubscription|[[Policy](#schemapolicy)]|false|none|Policies applied when a client unsubscribes from this channel| -|on_message_received|[[Policy](#schemapolicy)]|false|none|Policies applied when a message is received for this channel| -|on_message_delivery|[[Policy](#schemapolicy)]|false|none|Policies applied when delivering a message for this channel| - -

Channel

- - - - - - -```json -{ - "name": "issues", - "method": "SUB", - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] -} - -``` - -Channel (topic/event stream) definition for async APIs. - -### Properties - -|Name|Type|Required|Restrictions|Description| -|---|---|---|---|---| -|name|string|true|none|Channel name or topic identifier relative to API context.| -|method|string|true|none|Operation method type.| -|policies|[[Policy](#schemapolicy)]|false|none|List of policies applied only to this channel (overrides or adds to API-level policies)| - -#### Enumerated Values - -|Property|Value| -|---|---| -|method|SUB| - -

WebBrokerApiRequest

- - - - - - -```json -{ - "apiVersion": "gateway.api-platform.wso2.com/v1", - "kind": "WebBrokerApi", - "metadata": { - "name": "stock-trading-v1.0" - }, - "spec": { - "displayName": "Stock Trading WebBroker API", - "version": "v1.0", - "context": "/stock-trading/$version", - "receiver": { - "name": "websocket-receiver", - "type": "websocket" - }, - "broker": { - "name": "kafka-driver", - "type": "kafka", - "properties": { - "brokers": [ - "kafka-broker-1:9092", - "kafka-broker-2:9092" - ] - } - }, - "allChannels": { - "on_connection_init": { - "policies": [] - }, - "on_produce": { - "policies": [] - }, - "on_consume": { - "policies": [] - } - }, - "channels": { - "prices": { - "produceTo": { - "topic": "stock.prices" - }, - "consumeFrom": { - "topic": "stock.prices" - }, - "on_connection_init": { - "policies": [] - }, - "on_produce": { - "policies": [] - }, - "on_consume": { - "policies": [] - } - } - } - } -} - -``` - -### Properties - -|Name|Type|Required|Restrictions|Description| -|---|---|---|---|---| -|apiVersion|string|true|none|API specification version| -|kind|string|true|none|API type| -|metadata|[Metadata](#schemametadata)|true|none|none| -|spec|[WebBrokerApiData](#schemawebbrokerapidata)|true|none|none| - -#### Enumerated Values - -|Property|Value| -|---|---| -|apiVersion|gateway.api-platform.wso2.com/v1| -|kind|WebBrokerApi| - -

WebBrokerApi

- - - - - - -```json -{ - "apiVersion": "gateway.api-platform.wso2.com/v1", - "kind": "WebBrokerApi", - "metadata": { - "name": "stock-trading-v1.0" - }, - "spec": { - "displayName": "Stock Trading WebBroker API", - "version": "v1.0", - "context": "/stock-trading/$version", - "receiver": { - "name": "websocket-receiver", - "type": "websocket" - }, - "broker": { - "name": "kafka-driver", - "type": "kafka", - "properties": { - "brokers": [ - "kafka-broker-1:9092", - "kafka-broker-2:9092" - ] - } - }, - "allChannels": { - "on_connection_init": { - "policies": [] - }, - "on_produce": { - "policies": [] - }, - "on_consume": { - "policies": [] - } - }, - "channels": { - "prices": { - "produceTo": { - "topic": "stock.prices" - }, - "consumeFrom": { - "topic": "stock.prices" - }, - "on_connection_init": { - "policies": [] - }, - "on_produce": { - "policies": [] - }, - "on_consume": { - "policies": [] - } - } - } - }, - "status": { - "id": "stock-trading-v1.0", - "state": "deployed", - "createdAt": "2026-04-24T07:21:13Z", - "updatedAt": "2026-04-24T07:21:13Z", - "deployedAt": "2026-04-24T07:21:13Z" - } -} - -``` - -### Properties - -allOf - -|Name|Type|Required|Restrictions|Description| -|---|---|---|---|---| -|*anonymous*|[WebBrokerApiRequest](#schemawebbrokerapirequest)|false|none|none| - -and - -|Name|Type|Required|Restrictions|Description| -|---|---|---|---|---| -|*anonymous*|object|false|none|none| -|» status|[ResourceStatus](#schemaresourcestatus)|false|read-only|Server-managed lifecycle fields. Populated on responses.| - -

WebBrokerApiData

- - - - - - -```json -{ - "displayName": "Stock Trading WebBroker API", - "version": "v1.0", - "context": "/stock-trading", - "receiver": { - "name": "websocket-receiver", - "type": "websocket", - "properties": {} - }, - "broker": { - "name": "kafka-driver", - "type": "kafka", - "properties": { - "brokers": [ - "kafka-broker-1:9092", - "kafka-broker-2:9092" - ] - } - }, - "allChannels": { - "on_connection_init": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - }, - "on_produce": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - }, - "on_consume": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - } - }, - "channels": { - "property1": { - "produceTo": { - "topic": "stock.prices" - }, - "consumeFrom": { - "topic": "stock.prices" - }, - "on_connection_init": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - }, - "on_produce": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - }, - "on_consume": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - } - }, - "property2": { - "produceTo": { - "topic": "stock.prices" - }, - "consumeFrom": { - "topic": "stock.prices" - }, - "on_connection_init": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - }, - "on_produce": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - }, - "on_consume": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - } - } - }, - "vhosts": { - "main": "api.example.com", - "sandbox": "sandbox-api.example.com" - }, - "deploymentState": "deployed" -} - -``` - -### Properties - -|Name|Type|Required|Restrictions|Description| -|---|---|---|---|---| -|displayName|string|true|none|Human-readable API name (must be URL-friendly - only letters, numbers, spaces, hyphens, underscores, and dots allowed)| -|version|string|true|none|Semantic version of the API| -|context|string|true|none|Base path for all API routes (must start with /, no trailing slash)| -|receiver|[WebBrokerApiReceiver](#schemawebbrokerapireceiver)|true|none|WebSocket receiver configuration| -|broker|[WebBrokerApiBroker](#schemawebbrokerapibroker)|true|none|Message broker driver configuration| -|allChannels|[WebBrokerApiAllChannelPolicies](#schemawebbrokerapiallchannelpolicies)|false|none|Protocol mediation policies applied to all channels| -|channels|object|true|none|Map of WebSocket channels for bidirectional streaming with Kafka (key is channel name)| -|» **additionalProperties**|[WebBrokerApiChannel](#schemawebbrokerapichannel)|false|none|WebSocket channel configuration with Kafka topic mapping| -|vhosts|object|false|none|Custom virtual hosts/domains for the API| -|» main|string|true|none|Custom virtual host/domain for production traffic| -|» sandbox|string|false|none|Custom virtual host/domain for sandbox traffic| -|deploymentState|string|false|none|Desired deployment state - 'deployed' (default) or 'undeployed'. When set to 'undeployed', the API is removed from router traffic but configuration and policies are preserved for potential redeployment.| - -#### Enumerated Values - -|Property|Value| -|---|---| -|deploymentState|deployed| -|deploymentState|undeployed| - -

WebBrokerApiReceiver

- - - - - - -```json -{ - "name": "websocket-receiver", - "type": "websocket", - "properties": {} -} - -``` - -WebSocket receiver configuration - -### Properties - -|Name|Type|Required|Restrictions|Description| -|---|---|---|---|---| -|name|string|true|none|Receiver name| -|type|string|true|none|Receiver type| -|properties|object|false|none|Additional receiver properties| - -

WebBrokerApiBroker

- - - - - - -```json -{ - "name": "kafka-driver", - "type": "kafka", - "properties": { - "brokers": [ - "kafka-broker-1:9092", - "kafka-broker-2:9092" - ] - } -} - -``` - -Message broker driver configuration - -### Properties - -|Name|Type|Required|Restrictions|Description| -|---|---|---|---|---| -|name|string|true|none|Broker driver name| -|type|string|true|none|Broker driver type| -|properties|object|true|none|Broker driver properties (e.g., bootstrap servers)| - -

WebBrokerApiAllChannelPolicies

- - - - - - -```json -{ - "on_connection_init": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - }, - "on_produce": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - }, - "on_consume": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - } -} +```json +"GET" ``` -Protocol mediation policies applied to all channels +HTTP method ### Properties |Name|Type|Required|Restrictions|Description| |---|---|---|---|---| -|on_connection_init|[WebBrokerApiPolicyGroup](#schemawebbrokerapipolicygroup)|false|none|Group of policies| -|on_produce|[WebBrokerApiPolicyGroup](#schemawebbrokerapipolicygroup)|false|none|Group of policies| -|on_consume|[WebBrokerApiPolicyGroup](#schemawebbrokerapipolicygroup)|false|none|Group of policies| +|*anonymous*|string|false|none|HTTP method| + +#### Enumerated Values + +|Property|Value| +|---|---| +|*anonymous*|GET| +|*anonymous*|POST| +|*anonymous*|PUT| +|*anonymous*|DELETE| +|*anonymous*|PATCH| +|*anonymous*|HEAD| +|*anonymous*|OPTIONS| -

WebBrokerApiPolicyGroup

+

OperationMatch

- - - - + + + + ```json { - "policies": [ + "method": "GET", + "path": { + "value": "/books/{id}", + "type": "Exact" + }, + "headers": [ { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} + "name": "version", + "value": "one", + "type": "Exact" } ] } ``` -Group of policies +Request matching criteria for an operation. Extensible with query params, cookies, etc. ### Properties |Name|Type|Required|Restrictions|Description| |---|---|---|---|---| -|policies|[[Policy](#schemapolicy)]|false|none|List of policies to apply| +|method|[OperationMethod](#schemaoperationmethod)|true|none|HTTP method| +|path|[OperationPathMatch](#schemaoperationpathmatch)|true|none|none| +|headers|[[OperationHeaderMatch](#schemaoperationheadermatch)]|false|none|Header matchers ANDed with the path match for Envoy route selection| -

WebBrokerApiChannel

+

OperationPathMatch

- - - - + + + + ```json { - "produceTo": { - "topic": "stock.prices" - }, - "consumeFrom": { - "topic": "stock.prices" - }, - "on_connection_init": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - }, - "on_produce": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - }, - "on_consume": { - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} - } - ] - } + "value": "/books/{id}", + "type": "Exact" } ``` -WebSocket channel configuration with Kafka topic mapping - ### Properties |Name|Type|Required|Restrictions|Description| |---|---|---|---|---| -|produceTo|[WebBrokerApiProduceConfig](#schemawebbrokerapiproduceconfig)|false|none|Configuration for producing messages from WebSocket to Kafka| -|consumeFrom|[WebBrokerApiConsumeConfig](#schemawebbrokerapiconsumeconfig)|false|none|Configuration for consuming messages from Kafka to WebSocket| -|on_connection_init|[WebBrokerApiPolicyGroup](#schemawebbrokerapipolicygroup)|false|none|Group of policies| -|on_produce|[WebBrokerApiPolicyGroup](#schemawebbrokerapipolicygroup)|false|none|Group of policies| -|on_consume|[WebBrokerApiPolicyGroup](#schemawebbrokerapipolicygroup)|false|none|Group of policies| +|value|string|true|none|Route path with optional {param} placeholders| +|type|string|false|none|Path matching semantics for the operation route| + +#### Enumerated Values + +|Property|Value| +|---|---| +|type|Exact| +|type|PathPrefix| -

WebBrokerApiProduceConfig

+

OperationHeaderMatch

- - - - + + + + ```json { - "topic": "stock.prices" + "name": "version", + "value": "one", + "type": "Exact" } ``` -Configuration for producing messages from WebSocket to Kafka +### Properties + +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|name|string|true|none|Header name (case-insensitive)| +|value|string|true|none|Header value to match| +|type|string|false|none|Header match type| + +#### Enumerated Values + +|Property|Value| +|---|---| +|type|Exact| +|type|RegularExpression| + +

Policy

+ + + + + + +```json +{ + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} +} + +``` ### Properties |Name|Type|Required|Restrictions|Description| |---|---|---|---|---| -|topic|string|true|none|Kafka topic to produce messages to| +|name|string|true|none|Name of the policy| +|version|string|true|none|Version of the policy. Only major-only version is allowed (e.g., v0, v1). Full semantic version (e.g., v1.0.0) is not accepted and will be rejected. The Gateway Controller resolves the major version to the single matching full version installed in the gateway image.| +|executionCondition|string|false|none|Expression controlling conditional execution of the policy| +|params|object|false|none|Arbitrary parameters for the policy (free-form key/value structure)| -

WebBrokerApiConsumeConfig

+

Channel

- - - - + + + + ```json { - "topic": "stock.prices" + "name": "issues", + "method": "SUB", + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] } ``` -Configuration for consuming messages from Kafka to WebSocket +Channel (topic/event stream) definition for async APIs. ### Properties |Name|Type|Required|Restrictions|Description| |---|---|---|---|---| -|topic|string|true|none|Kafka topic to consume messages from| +|name|string|true|none|Channel name or topic identifier relative to API context.| +|method|string|true|none|Operation method type.| +|policies|[[Policy](#schemapolicy)]|false|none|List of policies applied only to this channel (overrides or adds to API-level policies)| + +#### Enumerated Values + +|Property|Value| +|---|---| +|method|SUB|

APIKeyCreationRequest

@@ -2270,6 +1380,21 @@ and "context": "/everything", "specVersion": "2025-06-18", "vhost": "mcp1.example.com", + "upstreamDefinitions": [ + { + "name": "my-upstream-1", + "basePath": "/api/v2", + "timeout": { + "connect": "5s" + }, + "upstreams": [ + { + "url": "http://prod-backend-1:5000", + "weight": 80 + } + ] + } + ], "upstream": { "url": "http://prod-backend:5000/api/v2", "ref": "string", @@ -2322,7 +1447,11 @@ and ] } ], - "deploymentState": "deployed" + "deploymentState": "deployed", + "resilience": { + "timeout": "15s", + "idleTimeout": "0s" + } } ``` @@ -2336,6 +1465,7 @@ and |context|string|false|none|MCP Proxy context path| |specVersion|string|false|none|MCP specification version| |vhost|string|false|none|Virtual host name used for routing. Supports standard domain names, subdomains, or wildcard domains. Must follow RFC-compliant hostname rules. Wildcards are only allowed in the left-most label (e.g., *.example.com).| +|upstreamDefinitions|[[UpstreamDefinition](#schemaupstreamdefinition)]|false|none|List of reusable upstream definitions with optional timeout configurations. Referenced by upstream.ref.| |upstream|any|true|none|The backend MCP server url and auth configurations| allOf @@ -2359,6 +1489,7 @@ continued |resources|[[MCPResource](#schemamcpresource)]|false|none|none| |prompts|[[MCPPrompt](#schemamcpprompt)]|false|none|none| |deploymentState|string|false|none|Desired deployment state - 'deployed' (default) or 'undeployed'. When set to 'undeployed', the MCP Proxy is removed from router traffic but configuration and policies are preserved for potential redeployment.| +|resilience|[Resilience](#schemaresilience)|false|none|API-level backend/route timeout configuration. Applies to the traffic-forwarding routes generated for this MCP proxy (GET/POST/DELETE on the MCP resource path). Supported at the API level only. Because MCP transports are long-lived streams, the route timeout defaults to disabled ("0s") for MCP unless a timeout is set here (unlike REST/LLM, which fall back to the gateway's global route timeout); the idle timeout remains the liveness guard.| #### Enumerated Values @@ -3018,6 +2149,21 @@ and "context": "/openai", "vhost": "api.openai.com", "template": "openai", + "upstreamDefinitions": [ + { + "name": "my-upstream-1", + "basePath": "/api/v2", + "timeout": { + "connect": "5s" + }, + "upstreams": [ + { + "url": "http://prod-backend-1:5000", + "weight": 80 + } + ] + } + ], "upstream": { "url": "http://prod-backend:5000/api/v2", "ref": "string", @@ -3078,7 +2224,11 @@ and ] } ], - "deploymentState": "deployed" + "deploymentState": "deployed", + "resilience": { + "timeout": "15s", + "idleTimeout": "0s" + } } ``` @@ -3092,6 +2242,7 @@ and |context|string|false|none|Base path for all API routes (must start with /, no trailing slash)| |vhost|string|false|none|Virtual host name used for routing. Supports standard domain names, subdomains, or wildcard domains. Must follow RFC-compliant hostname rules. Wildcards are only allowed in the left-most label (e.g., *.example.com).| |template|string|true|none|Template name to use for this LLM Provider| +|upstreamDefinitions|[[UpstreamDefinition](#schemaupstreamdefinition)]|false|none|List of reusable upstream definitions with optional timeout configurations. Referenced by upstream.ref.| |upstream|any|true|none|none| allOf @@ -3115,6 +2266,7 @@ continued |operationPolicies|[[OperationPolicy](#schemaoperationpolicy)]|false|none|Operation-level policies scoped to specific paths/methods, evaluated after global policies.| |policies|[[LLMPolicy](#schemallmpolicy)]|false|none|DEPRECATED - use operationPolicies. Still honoured (treated identically to operationPolicies).| |deploymentState|string|false|none|Desired deployment state - 'deployed' (default) or 'undeployed'. When set to 'undeployed', the LLM Provider is removed from router traffic but configuration and policies are preserved for potential redeployment.| +|resilience|[Resilience](#schemaresilience)|false|none|API-level backend/route timeout configuration. Applies to all routes generated for this LLM Provider (the routes that forward traffic upstream). Supported at the API level only - LLM routes are synthesized by the gateway, so there is no operation-level override.| #### Enumerated Values @@ -3155,6 +2307,8 @@ continued |Property|Value| |---|---| |type|api-key| +|type|other| +|type|none|

LLMUpstreamAuth

@@ -3185,6 +2339,8 @@ continued |Property|Value| |---|---| |type|api-key| +|type|other| +|type|none|

LLMProxyProvider

@@ -3212,6 +2368,68 @@ continued |id|string|true|none|Unique id of a deployed llm provider| |auth|[LLMUpstreamAuth](#schemallmupstreamauth)|false|none|none| +

LLMProxyAdditionalProvider

+ + + + + + +```json +{ + "id": "anthropic-provider", + "as": "anthropic-upstream", + "auth": { + "type": "api-key", + "header": "string", + "value": "string" + }, + "transformer": { + "type": "openai-to-anthropic", + "version": "v1", + "params": {} + } +} + +``` + +Additional LLM provider attached to this proxy as a selectable upstream. Policies route to it by referring to the `as` name (defaults to `id`). Optional auth config is used by the proxy when calling a protected LlmProvider over the internal loopback route. + +### Properties + +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|id|string|true|none|Unique id of a deployed llm provider| +|as|string|false|none|Logical LLM Provider name used by policies to select this provider. Must be unique within the proxy. Defaults to `id` when omitted.| +|auth|[LLMUpstreamAuth](#schemallmupstreamauth)|false|none|none| +|transformer|[LLMProxyTransformer](#schemallmproxytransformer)|false|none|Request/response translator applied when this provider is the selected upstream. The proxy injects the translator as a conditional policy whose execution condition matches this provider, so it runs only when the provider is selected. The provider's `as` name (defaults to `id`) is passed to the translator as its target upstream.| + +

LLMProxyTransformer

+ + + + + + +```json +{ + "type": "openai-to-anthropic", + "version": "v1", + "params": {} +} + +``` + +Request/response translator applied when this provider is the selected upstream. The proxy injects the translator as a conditional policy whose execution condition matches this provider, so it runs only when the provider is selected. The provider's `as` name (defaults to `id`) is passed to the translator as its target upstream. + +### Properties + +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|type|string|true|none|Translator policy name (for example openai-to-anthropic).| +|version|string|true|none|Major-only translator policy version (for example v1). The Gateway Controller resolves it to the installed full version.| +|params|object|false|none|Translator-specific parameters (for example model, apiVersion).| +

LLMAccessControl

@@ -3527,6 +2745,22 @@ and ] } ], + "additionalProviders": [ + { + "id": "anthropic-provider", + "as": "anthropic-upstream", + "auth": { + "type": "api-key", + "header": "string", + "value": "string" + }, + "transformer": { + "type": "openai-to-anthropic", + "version": "v1", + "params": {} + } + } + ], "policies": [ { "name": "llm-cost-based-ratelimit", @@ -3542,7 +2776,11 @@ and ] } ], - "deploymentState": "deployed" + "deploymentState": "deployed", + "resilience": { + "timeout": "15s", + "idleTimeout": "0s" + } } ``` @@ -3558,8 +2796,10 @@ and |provider|[LLMProxyProvider](#schemallmproxyprovider)|true|none|none| |globalPolicies|[[Policy](#schemapolicy)]|false|none|Global (api-level) policies applied across ALL operations as one shared scope, evaluated before operation-level policies.| |operationPolicies|[[OperationPolicy](#schemaoperationpolicy)]|false|none|Operation-level policies scoped to specific paths/methods, evaluated after global policies.| +|additionalProviders|[[LLMProxyAdditionalProvider](#schemallmproxyadditionalprovider)]|false|none|Optional list of additional LLM providers attached to this proxy as selectable upstreams. Policies (e.g. an OpenAI translator) can route requests to any of these by setting the upstream name. The primary `provider` field above remains the default upstream and the FK target.| |policies|[[LLMPolicy](#schemallmpolicy)]|false|none|DEPRECATED - use operationPolicies. Still honoured (treated identically to operationPolicies).| |deploymentState|string|false|none|Desired deployment state - 'deployed' (default) or 'undeployed'. When set to 'undeployed', the LLM Proxy is removed from router traffic but configuration and policies are preserved for potential redeployment.| +|resilience|[Resilience](#schemaresilience)|false|none|API-level backend/route timeout configuration. Applies to all routes generated for this LLM Proxy (the routes that forward traffic upstream). Supported at the API level only - LLM routes are synthesized by the gateway, so there is no operation-level override.| #### Enumerated Values @@ -3880,127 +3120,6 @@ and |totalCount|integer|false|none|Total number of API keys| |status|string|false|none|none| -

WebhookSecretCreationRequest

- - - - - - -```json -{ - "displayName": "GitHub Webhook" -} - -``` - -### Properties - -|Name|Type|Required|Restrictions|Description| -|---|---|---|---|---| -|displayName|string|true|none|Human-readable label for this secret (used to derive the immutable name slug).| - -

WebhookSecretInfo

- - - - - - -```json -{ - "name": "github-webhook", - "displayName": "GitHub Webhook", - "status": "active", - "createdAt": "2026-06-01T10:00:00Z", - "updatedAt": "2026-06-01T10:00:00Z" -} - -``` - -Metadata for an HMAC secret. The plaintext value is never included. - -### Properties - -|Name|Type|Required|Restrictions|Description| -|---|---|---|---|---| -|name|string|false|none|URL-safe slug (immutable, used as path parameter for regenerate/delete).| -|displayName|string|false|none|Human-readable label.| -|status|string|false|none|none| -|createdAt|string(date-time)|false|none|none| -|updatedAt|string(date-time)|false|none|none| - -#### Enumerated Values - -|Property|Value| -|---|---| -|status|active| -|status|revoked| - -

WebhookSecretCreationResponse

- - - - - - -```json -{ - "status": "success", - "message": "Webhook secret generated successfully", - "secret": "whsec_1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b", - "webhookSecret": { - "name": "github-webhook", - "displayName": "GitHub Webhook", - "status": "active", - "createdAt": "2026-06-01T10:00:00Z", - "updatedAt": "2026-06-01T10:00:00Z" - } -} - -``` - -### Properties - -|Name|Type|Required|Restrictions|Description| -|---|---|---|---|---| -|status|string|true|none|none| -|message|string|true|none|none| -|secret|string|true|none|The generated plaintext secret value (whsec_ prefix + 64 hex chars).
Returned exactly once — store it immediately as it will not be retrievable again.| -|webhookSecret|[WebhookSecretInfo](#schemawebhooksecretinfo)|false|none|Metadata for an HMAC secret. The plaintext value is never included.| - -

WebhookSecretListResponse

- - - - - - -```json -{ - "status": "success", - "totalCount": 2, - "secrets": [ - { - "name": "github-webhook", - "displayName": "GitHub Webhook", - "status": "active", - "createdAt": "2026-06-01T10:00:00Z", - "updatedAt": "2026-06-01T10:00:00Z" - } - ] -} - -``` - -### Properties - -|Name|Type|Required|Restrictions|Description| -|---|---|---|---|---| -|status|string|false|none|none| -|totalCount|integer|false|none|Total number of active secrets for this API| -|secrets|[[WebhookSecretInfo](#schemawebhooksecretinfo)]|false|none|[Metadata for an HMAC secret. The plaintext value is never included.]| -

SecretListResponse

diff --git a/gateway/gateway-controller/api/management-openapi.yaml b/gateway/gateway-controller/api/management-openapi.yaml index 0768387e3..3d2677ad1 100644 --- a/gateway/gateway-controller/api/management-openapi.yaml +++ b/gateway/gateway-controller/api/management-openapi.yaml @@ -4353,7 +4353,7 @@ components: properties: type: type: string - enum: [ api-key ] + enum: [ api-key, other, none ] header: type: string value: @@ -4366,7 +4366,7 @@ components: properties: type: type: string - enum: [ api-key ] + enum: [ api-key, other, none ] header: type: string value: diff --git a/gateway/gateway-controller/pkg/api/management/generated.go b/gateway/gateway-controller/pkg/api/management/generated.go index 778d294b8..1fbde7af3 100644 --- a/gateway/gateway-controller/pkg/api/management/generated.go +++ b/gateway/gateway-controller/pkg/api/management/generated.go @@ -106,6 +106,8 @@ const ( // Defines values for LLMProviderConfigDataUpstreamAuthType. const ( LLMProviderConfigDataUpstreamAuthTypeApiKey LLMProviderConfigDataUpstreamAuthType = "api-key" + LLMProviderConfigDataUpstreamAuthTypeNone LLMProviderConfigDataUpstreamAuthType = "none" + LLMProviderConfigDataUpstreamAuthTypeOther LLMProviderConfigDataUpstreamAuthType = "other" ) // Defines values for LLMProviderConfigDataUpstreamHostRewrite. @@ -183,6 +185,8 @@ const ( // Defines values for LLMUpstreamAuthType. const ( LLMUpstreamAuthTypeApiKey LLMUpstreamAuthType = "api-key" + LLMUpstreamAuthTypeNone LLMUpstreamAuthType = "none" + LLMUpstreamAuthTypeOther LLMUpstreamAuthType = "other" ) // Defines values for MCPProxyConfigDataDeploymentState. @@ -194,6 +198,8 @@ const ( // Defines values for MCPProxyConfigDataUpstreamAuthType. const ( MCPProxyConfigDataUpstreamAuthTypeApiKey MCPProxyConfigDataUpstreamAuthType = "api-key" + MCPProxyConfigDataUpstreamAuthTypeNone MCPProxyConfigDataUpstreamAuthType = "none" + MCPProxyConfigDataUpstreamAuthTypeOther MCPProxyConfigDataUpstreamAuthType = "other" ) // Defines values for MCPProxyConfigDataUpstreamHostRewrite. @@ -396,6 +402,8 @@ const ( // Defines values for UpstreamAuthAuthType. const ( UpstreamAuthAuthTypeApiKey UpstreamAuthAuthType = "api-key" + UpstreamAuthAuthTypeNone UpstreamAuthAuthType = "none" + UpstreamAuthAuthTypeOther UpstreamAuthAuthType = "other" ) // Defines values for ListLLMProvidersParamsStatus. @@ -4639,164 +4647,164 @@ var swaggerSpec = []string{ "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", - "AAD//7xP39op1QEA", + "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", + "4/by9v8HAAD///2qi+lH1QEA", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/gateway/gateway-controller/pkg/config/llm_validator.go b/gateway/gateway-controller/pkg/config/llm_validator.go index dbd7720f6..cfad4fa3d 100644 --- a/gateway/gateway-controller/pkg/config/llm_validator.go +++ b/gateway/gateway-controller/pkg/config/llm_validator.go @@ -483,15 +483,18 @@ func (v *LLMValidator) validateUpstreamWithAuth(fieldPrefix string, Field: fmt.Sprintf("%s.auth.type", fieldPrefix), Message: "Auth type is required", }) - } else if auth.Type != "api-key" { + } else if auth.Type != api.LLMProviderConfigDataUpstreamAuthTypeApiKey && + auth.Type != api.LLMProviderConfigDataUpstreamAuthTypeOther && + auth.Type != api.LLMProviderConfigDataUpstreamAuthTypeNone { errors = append(errors, ValidationError{ Field: fmt.Sprintf("%s.auth.type", fieldPrefix), - Message: "Auth type must be 'api-key'", + Message: "Auth type must be one of 'api-key', 'other', 'none'", }) } - // If type is api-key, header and value should also be present - if auth.Type == "api-key" { + // Header and value are only meaningful for api-key; for 'other'/'none' + // authentication is handled by user-attached policies (or not at all). + if auth.Type == api.LLMProviderConfigDataUpstreamAuthTypeApiKey { if auth.Header == nil || *auth.Header == "" { errors = append(errors, ValidationError{ Field: fmt.Sprintf("%s.auth.header", fieldPrefix), @@ -702,12 +705,16 @@ func (v *LLMValidator) validateLLMUpstreamAuth(fieldPrefix string, auth *api.LLM Field: fieldPrefix + ".type", Message: "Auth type is required", }) - } else if auth.Type != api.LLMUpstreamAuthTypeApiKey { + } else if auth.Type != api.LLMUpstreamAuthTypeApiKey && + auth.Type != api.LLMUpstreamAuthTypeOther && + auth.Type != api.LLMUpstreamAuthTypeNone { errors = append(errors, ValidationError{ Field: fieldPrefix + ".type", - Message: "Auth type must be 'api-key'", + Message: "Auth type must be one of 'api-key', 'other', 'none'", }) } + // Header and value are only meaningful for api-key; for 'other'/'none' + // authentication is handled by user-attached policies (or not at all). if auth.Type == api.LLMUpstreamAuthTypeApiKey { if auth.Header == nil || *auth.Header == "" { errors = append(errors, ValidationError{ diff --git a/gateway/gateway-controller/pkg/config/llm_validator_test.go b/gateway/gateway-controller/pkg/config/llm_validator_test.go index 86b5d9f2c..364a9e325 100644 --- a/gateway/gateway-controller/pkg/config/llm_validator_test.go +++ b/gateway/gateway-controller/pkg/config/llm_validator_test.go @@ -1499,6 +1499,28 @@ func TestValidateLLMProvider_UpstreamAuth(t *testing.T) { }, expectError: false, }, + { + name: "valid other auth without header or value", + auth: &struct { + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` + }{ + Type: api.LLMProviderConfigDataUpstreamAuthTypeOther, + }, + expectError: false, + }, + { + name: "valid none auth without header or value", + auth: &struct { + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` + }{ + Type: api.LLMProviderConfigDataUpstreamAuthTypeNone, + }, + expectError: false, + }, } validator := NewLLMValidator() diff --git a/gateway/gateway-controller/pkg/utils/llm_provider_transformer_test.go b/gateway/gateway-controller/pkg/utils/llm_provider_transformer_test.go index 5c219a3e1..4307f05ad 100644 --- a/gateway/gateway-controller/pkg/utils/llm_provider_transformer_test.go +++ b/gateway/gateway-controller/pkg/utils/llm_provider_transformer_test.go @@ -523,6 +523,59 @@ func TestTransform_ApiKeyAuth(t *testing.T) { } } +// TestTransform_OtherAndNoneAuth verifies that "other" and "none" upstream auth +// types transform successfully but attach no upstream auth policy - for "other" +// authentication is handled by user-attached policies, for "none" there is none. +func TestTransform_OtherAndNoneAuth(t *testing.T) { + for _, authType := range []api.LLMProviderConfigDataUpstreamAuthType{ + api.LLMProviderConfigDataUpstreamAuthTypeOther, + api.LLMProviderConfigDataUpstreamAuthTypeNone, + } { + t.Run(string(authType), func(t *testing.T) { + transformer, _ := setupTestTransformer(t) + + provider := &api.LLMProviderConfiguration{ + ApiVersion: "gateway.api-platform.wso2.com/v1", + Kind: "LlmProvider", + Metadata: api.Metadata{Name: "openai-provider"}, + Spec: api.LLMProviderConfigData{ + DisplayName: "test", + Version: "v1.0", + Template: "openai", + Upstream: api.LLMProviderConfigData_Upstream{ + Url: stringPtr("https://api.example.com"), + Auth: &struct { + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` + }{ + Type: authType, + }, + }, + AccessControl: api.LLMAccessControl{ + Mode: api.AllowAll, + }, + }, + } + + output := &api.RestAPI{} + result, err := transformer.Transform(provider, output) + require.NoError(t, err) + + // No upstream auth policy should be attached to any operation. + for _, op := range result.Spec.Operations { + if op.Policies == nil { + continue + } + for _, pol := range *op.Policies { + assert.NotEqual(t, constants.UPSTREAM_AUTH_APIKEY_POLICY_NAME, pol.Name, + "auth type %q should not attach an upstream auth policy", authType) + } + } + }) + } +} + func TestTransform_UnsupportedAuthType(t *testing.T) { transformer, _ := setupTestTransformer(t) diff --git a/gateway/gateway-controller/pkg/utils/llm_transformer.go b/gateway/gateway-controller/pkg/utils/llm_transformer.go index 5d315f9e6..ab2d1b3f0 100644 --- a/gateway/gateway-controller/pkg/utils/llm_transformer.go +++ b/gateway/gateway-controller/pkg/utils/llm_transformer.go @@ -235,9 +235,12 @@ func (t *LLMProviderTransformer) transformProxy(proxy *api.LLMProxyConfiguration if err != nil { return nil, err } - condition := selectedProviderExecutionCondition(proxy.Spec.Provider.Id, true) - pol.ExecutionCondition = &condition - upstreamAuthPolicies = append(upstreamAuthPolicies, *pol) + // "other"/"none" auth yield no policy - nothing to attach. + if pol != nil { + condition := selectedProviderExecutionCondition(proxy.Spec.Provider.Id, true) + pol.ExecutionCondition = &condition + upstreamAuthPolicies = append(upstreamAuthPolicies, *pol) + } } if proxy.Spec.AdditionalProviders != nil { for _, ap := range *proxy.Spec.AdditionalProviders { @@ -251,9 +254,12 @@ func (t *LLMProviderTransformer) transformProxy(proxy *api.LLMProxyConfiguration if err != nil { return nil, err } - condition := selectedProviderExecutionCondition(name, false) - pol.ExecutionCondition = &condition - upstreamAuthPolicies = append(upstreamAuthPolicies, *pol) + // "other"/"none" auth yield no policy - nothing to attach. + if pol != nil { + condition := selectedProviderExecutionCondition(name, false) + pol.ExecutionCondition = &condition + upstreamAuthPolicies = append(upstreamAuthPolicies, *pol) + } } if ap.Transformer != nil { @@ -438,6 +444,11 @@ func (t *LLMProviderTransformer) transformProvider(provider *api.LLMProviderConf Name: constants.UPSTREAM_AUTH_APIKEY_POLICY_NAME, Version: policyVersion, Params: ¶ms} upstreamAuthPolicy = &mh + case api.LLMProviderConfigDataUpstreamAuthTypeOther, + api.LLMProviderConfigDataUpstreamAuthTypeNone: + // "other": auth handled entirely by user-attached policies. + // "none": no upstream authentication. In both cases the gateway + // attaches no auth policy of its own. default: return nil, fmt.Errorf("unsupported upstream auth type: %s", upstream.Auth.Type) } @@ -782,6 +793,10 @@ func (t *LLMProviderTransformer) proxyUpstreamAuthPolicy(auth *api.LLMUpstreamAu Version: policyVersion, Params: ¶ms, }, nil + case api.LLMUpstreamAuthTypeOther, api.LLMUpstreamAuthTypeNone: + // "other": auth handled entirely by user-attached policies. + // "none": no upstream authentication. No auth policy is attached. + return nil, nil default: return nil, fmt.Errorf("unsupported upstream auth type: %s", auth.Type) } diff --git a/gateway/gateway-controller/pkg/utils/llm_transformer_test.go b/gateway/gateway-controller/pkg/utils/llm_transformer_test.go index 25af0e5de..cdd55021b 100644 --- a/gateway/gateway-controller/pkg/utils/llm_transformer_test.go +++ b/gateway/gateway-controller/pkg/utils/llm_transformer_test.go @@ -2113,6 +2113,88 @@ func TestTransformProxy_WithUpstreamAuth(t *testing.T) { } } +// TestTransformProxy_OtherAndNoneUpstreamAuth verifies that a proxy-level +// upstream auth override of "other" or "none" transforms successfully and +// attaches no upstream auth policy. +func TestTransformProxy_OtherAndNoneUpstreamAuth(t *testing.T) { + for _, authType := range []api.LLMUpstreamAuthType{ + api.LLMUpstreamAuthTypeOther, + api.LLMUpstreamAuthTypeNone, + } { + t.Run(string(authType), func(t *testing.T) { + store := storage.NewConfigStore() + db := newTestMockDB() + routerConfig := &config.RouterConfig{ListenerPort: 8080} + transformer := NewLLMProviderTransformer(store, db, routerConfig, newTestPolicyVersionResolver()) + + template := &models.StoredLLMProviderTemplate{ + UUID: "0000-template-1-0000-000000000000", + Configuration: api.LLMProviderTemplate{ + Metadata: api.Metadata{Name: "openai"}, + Spec: api.LLMProviderTemplateData{}, + }, + } + db.SaveLLMProviderTemplate(template) + require.NoError(t, store.AddTemplate(template)) + + upstreamURL := "https://api.openai.com" + provider := &api.LLMProviderConfiguration{ + Metadata: api.Metadata{Name: "openai-provider"}, + Spec: api.LLMProviderConfigData{ + DisplayName: "OpenAI Provider", + Version: "v1.0", + Template: "openai", + Upstream: api.LLMProviderConfigData_Upstream{Url: &upstreamURL}, + AccessControl: api.LLMAccessControl{Mode: api.AllowAll}, + }, + } + providerAPI, err := transformer.Transform(provider, &api.RestAPI{}) + require.NoError(t, err) + + storedProvider := &models.StoredConfig{ + UUID: "0000-prov-cfg-1-0000-000000000000", + Kind: string(api.LLMProviderConfigurationKindLlmProvider), + Handle: "openai-provider", + DisplayName: "OpenAI Provider", + Version: "v1.0", + Configuration: *providerAPI, + SourceConfiguration: *provider, + DesiredState: models.StateDeployed, + Origin: models.OriginGatewayAPI, + } + db.SaveConfig(storedProvider) + require.NoError(t, store.Add(storedProvider)) + + proxy := &api.LLMProxyConfiguration{ + Metadata: api.Metadata{Name: "openai-proxy"}, + Spec: api.LLMProxyConfigData{ + DisplayName: "OpenAI Proxy", + Version: "v1.0", + Provider: api.LLMProxyProvider{ + Id: "openai-provider", + Auth: &api.LLMUpstreamAuth{Type: authType}, + }, + }, + } + + result, err := transformer.Transform(proxy, &api.RestAPI{}) + require.NoError(t, err) + require.NotNil(t, result) + require.NotEmpty(t, result.Spec.Operations) + + for _, op := range result.Spec.Operations { + if op.Policies == nil { + continue + } + for _, p := range *op.Policies { + assert.NotEqual(t, constants.UPSTREAM_AUTH_APIKEY_POLICY_NAME, p.Name, + "proxy auth type %q should not attach an upstream auth policy", authType) + } + } + }) + } +} + func TestTransformProvider_UnsupportedMode(t *testing.T) { store := storage.NewConfigStore() db := newTestMockDB() diff --git a/platform-api/api/generated.go b/platform-api/api/generated.go index 3748ae810..af12e7329 100644 --- a/platform-api/api/generated.go +++ b/platform-api/api/generated.go @@ -318,6 +318,7 @@ const ( ApiKey UpstreamAuthType = "api-key" Basic UpstreamAuthType = "basic" Bearer UpstreamAuthType = "bearer" + None UpstreamAuthType = "none" Other UpstreamAuthType = "other" ) diff --git a/platform-api/internal/service/artifact_cross_origin_test.go b/platform-api/internal/service/artifact_cross_origin_test.go index 5a6631ed7..327b2b1ad 100644 --- a/platform-api/internal/service/artifact_cross_origin_test.go +++ b/platform-api/internal/service/artifact_cross_origin_test.go @@ -28,6 +28,7 @@ import ( "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/dto" "github.com/wso2/api-platform/platform-api/internal/repository" + "github.com/wso2/api-platform/platform-api/internal/vault" ) func strPointer(s string) *string { return &s } @@ -276,7 +277,7 @@ func TestCPProviderFromDPTemplate(t *testing.T) { created, err := providerSvc.Create(importTestOrgID, "tester", &api.LLMProvider{ Id: strPointer("cp-provider"), - DisplayName: "CP Provider", + DisplayName: "CP Provider", Version: "v1.0", Template: templateHandle, // references the DP template Upstream: api.Upstream{Main: api.UpstreamDefinition{Url: strPointer("https://api.openai.com")}}, @@ -318,11 +319,11 @@ func TestCPProxyFromDPProvider(t *testing.T) { ) created, err := proxySvc.Create(importTestOrgID, "tester", &api.LLMProxy{ - Id: strPointer("cp-proxy"), - DisplayName: "CP Proxy", - Version: "v1.0", - ProjectId: "default", // project handle (setupImportTest inserts handle "default") - Provider: api.LLMProxyProvider{Id: providerHandle}, // references the DP provider + Id: strPointer("cp-proxy"), + DisplayName: "CP Proxy", + Version: "v1.0", + ProjectId: "default", // project handle (setupImportTest inserts handle "default") + Provider: api.LLMProxyProvider{Id: providerHandle}, // references the DP provider }) if err != nil { t.Fatalf("create CP proxy from DP provider: %v", err) @@ -334,3 +335,335 @@ func TestCPProxyFromDPProvider(t *testing.T) { t.Errorf("CP proxy readOnly = %v, want false", created.ReadOnly) } } + +// TestLLMProviderCreate_DefaultsUpstreamAuthToNone verifies the provider CREATE path +// defaults an absent upstream auth to type "none" (matching the update path). +func TestLLMProviderCreate_DefaultsUpstreamAuthToNone(t *testing.T) { + d := setupImportTest(t) + const templateHandle = "dp-openai-template" + importDPTemplate(t, d, "dpt-0000-0000-0000-0000000000c1", templateHandle) + + repo := repository.NewLLMProviderRepo(d.db) + providerSvc := NewLLMProviderService( + repo, d.templateRepo, repository.NewOrganizationRepo(d.db), nil, d.deployment, + repository.NewGatewayRepo(d.db), nil, newTestLogger(), &noopAuditRepo{}, &config.Server{}, + NewIdentityService(repository.NewUserIdentityMappingRepo(d.db)), + ) + + created, err := providerSvc.Create(importTestOrgID, "tester", &api.LLMProvider{ + Id: strPointer("cp-create-none-prov"), DisplayName: "P", Version: "v1.0", Template: templateHandle, + Upstream: api.Upstream{Main: api.UpstreamDefinition{Url: strPointer("https://api.openai.com")}}, + AccessControl: api.LLMAccessControl{Mode: api.LLMAccessControlMode("deny_all")}, + }) + if err != nil { + t.Fatalf("create provider: %v", err) + } + + stored, err := repo.GetByID(*created.Id, importTestOrgID) + if err != nil || stored == nil { + t.Fatalf("load stored provider: %v", err) + } + if stored.Configuration.Upstream == nil || stored.Configuration.Upstream.Main == nil || + stored.Configuration.Upstream.Main.Auth == nil || + stored.Configuration.Upstream.Main.Auth.Type != "none" { + t.Fatalf("expected stored upstream auth type 'none' after create, got %+v", stored.Configuration.Upstream) + } +} + +// TestLLMProxyCreate_DefaultsUpstreamAuthToNone verifies the proxy CREATE path defaults an +// absent upstream auth override to type "none" (matching the update path). +func TestLLMProxyCreate_DefaultsUpstreamAuthToNone(t *testing.T) { + d := setupImportTest(t) + const templateHandle = "dp-openai-template" + const providerHandle = "dp-provider" + importDPTemplate(t, d, "dpt-0000-0000-0000-0000000000c2", templateHandle) + importDPProvider(t, d, "dpp-0000-0000-0000-0000000000c2", providerHandle, templateHandle) + + repo := repository.NewLLMProxyRepo(d.db) + proxySvc := NewLLMProxyService( + repo, repository.NewLLMProviderRepo(d.db), repository.NewProjectRepo(d.db), d.deployment, + repository.NewGatewayRepo(d.db), nil, newTestLogger(), &noopAuditRepo{}, &config.Server{}, + NewIdentityService(repository.NewUserIdentityMappingRepo(d.db)), + ) + + created, err := proxySvc.Create(importTestOrgID, "tester", &api.LLMProxy{ + Id: strPointer("cp-create-none-proxy"), DisplayName: "X", Version: "v1.0", ProjectId: "default", + Provider: api.LLMProxyProvider{Id: providerHandle}, + }) + if err != nil { + t.Fatalf("create proxy: %v", err) + } + + stored, err := repo.GetByID(*created.Id, importTestOrgID) + if err != nil || stored == nil { + t.Fatalf("load stored proxy: %v", err) + } + if stored.Configuration.UpstreamAuth == nil || stored.Configuration.UpstreamAuth.Type != "none" { + t.Fatalf("expected stored proxy upstream auth type 'none' after create, got %+v", stored.Configuration.UpstreamAuth) + } +} + +// TestLLMProviderUpdate_DefaultsUpstreamAuthToNone verifies the provider UPDATE path +// defaults an absent upstream auth to type "none": a provider created with api-key auth +// and then updated with the auth object omitted (explicit removal) is stored as "none", +// not left unset. +func TestLLMProviderUpdate_DefaultsUpstreamAuthToNone(t *testing.T) { + d := setupImportTest(t) + const templateHandle = "dp-openai-template" + importDPTemplate(t, d, "dpt-0000-0000-0000-0000000000a1", templateHandle) + + repo := repository.NewLLMProviderRepo(d.db) + providerSvc := NewLLMProviderService( + repo, d.templateRepo, repository.NewOrganizationRepo(d.db), nil, d.deployment, + repository.NewGatewayRepo(d.db), nil, newTestLogger(), &noopAuditRepo{}, &config.Server{}, + NewIdentityService(repository.NewUserIdentityMappingRepo(d.db)), + ) + + apiKey := api.UpstreamAuthType(api.ApiKey) + created, err := providerSvc.Create(importTestOrgID, "tester", &api.LLMProvider{ + Id: strPointer("cp-none-prov"), + DisplayName: "CP None Provider", + Version: "v1.0", + Template: templateHandle, + Upstream: api.Upstream{Main: api.UpstreamDefinition{ + Url: strPointer("https://api.openai.com"), + Auth: &api.UpstreamAuth{Type: &apiKey, Header: strPointer("X-API-Key"), Value: strPointer("secret")}, + }}, + AccessControl: api.LLMAccessControl{Mode: api.LLMAccessControlMode("deny_all")}, + }) + if err != nil { + t.Fatalf("create provider: %v", err) + } + + // Update omitting the upstream auth object entirely (explicit removal). + if _, err := providerSvc.Update(importTestOrgID, *created.Id, "tester", &api.LLMProvider{ + DisplayName: "CP None Provider", + Version: "v1.0", + Template: templateHandle, + Upstream: api.Upstream{Main: api.UpstreamDefinition{Url: strPointer("https://api.openai.com")}}, + AccessControl: api.LLMAccessControl{Mode: api.LLMAccessControlMode("deny_all")}, + }); err != nil { + t.Fatalf("update provider: %v", err) + } + + stored, err := repo.GetByID(*created.Id, importTestOrgID) + if err != nil || stored == nil { + t.Fatalf("load stored provider: %v", err) + } + if stored.Configuration.Upstream == nil || stored.Configuration.Upstream.Main == nil || + stored.Configuration.Upstream.Main.Auth == nil || + stored.Configuration.Upstream.Main.Auth.Type != "none" { + t.Fatalf("expected stored upstream auth type 'none' after update, got %+v", stored.Configuration.Upstream) + } +} + +// TestLLMProxyUpdate_DefaultsUpstreamAuthToNone verifies the proxy UPDATE path defaults an +// absent upstream auth override to type "none": a proxy created with no override and then +// updated with none supplied is stored as "none". +func TestLLMProxyUpdate_DefaultsUpstreamAuthToNone(t *testing.T) { + d := setupImportTest(t) + const templateHandle = "dp-openai-template" + const providerHandle = "dp-provider" + importDPTemplate(t, d, "dpt-0000-0000-0000-0000000000a2", templateHandle) + importDPProvider(t, d, "dpp-0000-0000-0000-0000000000a2", providerHandle, templateHandle) + + repo := repository.NewLLMProxyRepo(d.db) + proxySvc := NewLLMProxyService( + repo, repository.NewLLMProviderRepo(d.db), repository.NewProjectRepo(d.db), d.deployment, + repository.NewGatewayRepo(d.db), nil, newTestLogger(), &noopAuditRepo{}, &config.Server{}, + NewIdentityService(repository.NewUserIdentityMappingRepo(d.db)), + ) + + apiKey := api.UpstreamAuthType(api.ApiKey) + created, err := proxySvc.Create(importTestOrgID, "tester", &api.LLMProxy{ + Id: strPointer("cp-none-proxy"), + DisplayName: "CP None Proxy", + Version: "v1.0", + ProjectId: "default", + Provider: api.LLMProxyProvider{ + Id: providerHandle, + Auth: &api.UpstreamAuth{Type: &apiKey, Header: strPointer("X-API-Key"), Value: strPointer("sk-secret")}, + }, + }) + if err != nil { + t.Fatalf("create proxy: %v", err) + } + + // Update with the provider auth override omitted entirely. + if _, err := proxySvc.Update(importTestOrgID, *created.Id, "tester", &api.LLMProxy{ + DisplayName: "CP None Proxy", + Version: "v1.0", + ProjectId: "default", + Provider: api.LLMProxyProvider{Id: providerHandle}, + }); err != nil { + t.Fatalf("update proxy: %v", err) + } + + stored, err := repo.GetByID(*created.Id, importTestOrgID) + if err != nil || stored == nil { + t.Fatalf("load stored proxy: %v", err) + } + if stored.Configuration.UpstreamAuth == nil || stored.Configuration.UpstreamAuth.Type != "none" { + t.Fatalf("expected stored proxy upstream auth type 'none' after update, got %+v", stored.Configuration.UpstreamAuth) + } +} + +// TestLLMProviderUpdate_SwitchApiKeyToOther_ClearsStoredCredential verifies that switching an +// existing api-key provider to "other" drops the credential (header + value) from the stored +// artifact, so no stale credential lingers under a credential-less type. +func TestLLMProviderUpdate_SwitchApiKeyToOther_ClearsStoredCredential(t *testing.T) { + d := setupImportTest(t) + const templateHandle = "dp-openai-template" + importDPTemplate(t, d, "dpt-0000-0000-0000-0000000000b1", templateHandle) + + repo := repository.NewLLMProviderRepo(d.db) + providerSvc := NewLLMProviderService( + repo, d.templateRepo, repository.NewOrganizationRepo(d.db), nil, d.deployment, + repository.NewGatewayRepo(d.db), nil, newTestLogger(), &noopAuditRepo{}, &config.Server{}, + NewIdentityService(repository.NewUserIdentityMappingRepo(d.db)), + ) + + apiKey := api.UpstreamAuthType(api.ApiKey) + created, err := providerSvc.Create(importTestOrgID, "tester", &api.LLMProvider{ + Id: strPointer("cp-switch-prov"), DisplayName: "P", Version: "v1.0", Template: templateHandle, + Upstream: api.Upstream{Main: api.UpstreamDefinition{ + Url: strPointer("https://api.openai.com"), + Auth: &api.UpstreamAuth{Type: &apiKey, Header: strPointer("X-API-Key"), Value: strPointer("sk-secret")}, + }}, + AccessControl: api.LLMAccessControl{Mode: api.LLMAccessControlMode("deny_all")}, + }) + if err != nil { + t.Fatalf("create provider: %v", err) + } + + other := api.UpstreamAuthType(api.Other) + if _, err := providerSvc.Update(importTestOrgID, *created.Id, "tester", &api.LLMProvider{ + DisplayName: "P", Version: "v1.0", Template: templateHandle, + Upstream: api.Upstream{Main: api.UpstreamDefinition{ + Url: strPointer("https://api.openai.com"), + Auth: &api.UpstreamAuth{Type: &other}, + }}, + AccessControl: api.LLMAccessControl{Mode: api.LLMAccessControlMode("deny_all")}, + }); err != nil { + t.Fatalf("update provider: %v", err) + } + + stored, err := repo.GetByID(*created.Id, importTestOrgID) + if err != nil || stored == nil { + t.Fatalf("load stored provider: %v", err) + } + auth := stored.Configuration.Upstream.Main.Auth + if auth == nil || auth.Type != "other" { + t.Fatalf("expected stored auth type 'other', got %+v", auth) + } + if auth.Header != "" || auth.Value != "" { + t.Fatalf("expected cleared header/value after switch to other, got header=%q value=%q", auth.Header, auth.Value) + } +} + +// TestLLMProxyUpdate_SwitchApiKeyToOther_ClearsStoredCredential is the proxy counterpart. +func TestLLMProxyUpdate_SwitchApiKeyToOther_ClearsStoredCredential(t *testing.T) { + d := setupImportTest(t) + const templateHandle = "dp-openai-template" + const providerHandle = "dp-provider" + importDPTemplate(t, d, "dpt-0000-0000-0000-0000000000b2", templateHandle) + importDPProvider(t, d, "dpp-0000-0000-0000-0000000000b2", providerHandle, templateHandle) + + repo := repository.NewLLMProxyRepo(d.db) + proxySvc := NewLLMProxyService( + repo, repository.NewLLMProviderRepo(d.db), repository.NewProjectRepo(d.db), d.deployment, + repository.NewGatewayRepo(d.db), nil, newTestLogger(), &noopAuditRepo{}, &config.Server{}, + NewIdentityService(repository.NewUserIdentityMappingRepo(d.db)), + ) + + apiKey := api.UpstreamAuthType(api.ApiKey) + created, err := proxySvc.Create(importTestOrgID, "tester", &api.LLMProxy{ + Id: strPointer("cp-switch-proxy"), DisplayName: "X", Version: "v1.0", ProjectId: "default", + Provider: api.LLMProxyProvider{ + Id: providerHandle, + Auth: &api.UpstreamAuth{Type: &apiKey, Header: strPointer("X-API-Key"), Value: strPointer("sk-secret")}, + }, + }) + if err != nil { + t.Fatalf("create proxy: %v", err) + } + + other := api.UpstreamAuthType(api.Other) + if _, err := proxySvc.Update(importTestOrgID, *created.Id, "tester", &api.LLMProxy{ + DisplayName: "X", Version: "v1.0", ProjectId: "default", + Provider: api.LLMProxyProvider{Id: providerHandle, Auth: &api.UpstreamAuth{Type: &other}}, + }); err != nil { + t.Fatalf("update proxy: %v", err) + } + + stored, err := repo.GetByID(*created.Id, importTestOrgID) + if err != nil || stored == nil { + t.Fatalf("load stored proxy: %v", err) + } + auth := stored.Configuration.UpstreamAuth + if auth == nil || auth.Type != "other" { + t.Fatalf("expected stored proxy auth type 'other', got %+v", auth) + } + if auth.Header != "" || auth.Value != "" { + t.Fatalf("expected cleared header/value after switch to other, got header=%q value=%q", auth.Header, auth.Value) + } +} + +// TestLLMProviderUpdate_SwitchToOther_PreservesReferencedSecret verifies that switching an +// api-key provider (whose value is a {{ secret }} reference) to "other" drops the reference +// from the artifact but does NOT delete the underlying secret, since it may be referenced by +// other providers/proxies. +func TestLLMProviderUpdate_SwitchToOther_PreservesReferencedSecret(t *testing.T) { + d := setupImportTest(t) + const templateHandle = "dp-openai-template" + importDPTemplate(t, d, "dpt-0000-0000-0000-0000000000b3", templateHandle) + + v, err := vault.NewInHouseVault([]byte("12345678901234567890123456789012")) + if err != nil { + t.Fatalf("create vault: %v", err) + } + secretSvc := NewSecretService(repository.NewSecretRepo(d.db), v, + NewIdentityService(repository.NewUserIdentityMappingRepo(d.db))) + if _, err := secretSvc.Create(importTestOrgID, "tester", + &dto.CreateSecretRequest{Handle: "shared-key", DisplayName: "Shared Key", Value: "sk-shared"}); err != nil { + t.Fatalf("create secret: %v", err) + } + + repo := repository.NewLLMProviderRepo(d.db) + providerSvc := NewLLMProviderService( + repo, d.templateRepo, repository.NewOrganizationRepo(d.db), nil, d.deployment, + repository.NewGatewayRepo(d.db), nil, newTestLogger(), &noopAuditRepo{}, &config.Server{}, + NewIdentityService(repository.NewUserIdentityMappingRepo(d.db)), + ) + providerSvc.SetSecretService(secretSvc) + + apiKey := api.UpstreamAuthType(api.ApiKey) + created, err := providerSvc.Create(importTestOrgID, "tester", &api.LLMProvider{ + Id: strPointer("cp-secret-prov"), DisplayName: "P", Version: "v1.0", Template: templateHandle, + Upstream: api.Upstream{Main: api.UpstreamDefinition{ + Url: strPointer("https://api.openai.com"), + Auth: &api.UpstreamAuth{Type: &apiKey, Header: strPointer("X-API-Key"), Value: strPointer(`{{ secret "shared-key" }}`)}, + }}, + AccessControl: api.LLMAccessControl{Mode: api.LLMAccessControlMode("deny_all")}, + }) + if err != nil { + t.Fatalf("create provider: %v", err) + } + + other := api.UpstreamAuthType(api.Other) + if _, err := providerSvc.Update(importTestOrgID, *created.Id, "tester", &api.LLMProvider{ + DisplayName: "P", Version: "v1.0", Template: templateHandle, + Upstream: api.Upstream{Main: api.UpstreamDefinition{ + Url: strPointer("https://api.openai.com"), + Auth: &api.UpstreamAuth{Type: &other}, + }}, + AccessControl: api.LLMAccessControl{Mode: api.LLMAccessControlMode("deny_all")}, + }); err != nil { + t.Fatalf("update provider: %v", err) + } + + // The secret must still exist - the switch to "other" must not delete it. + if _, err := secretSvc.Get(importTestOrgID, "shared-key"); err != nil { + t.Fatalf("expected secret 'shared-key' to survive the switch to 'other', got: %v", err) + } +} diff --git a/platform-api/internal/service/artifact_import_llm_provider.go b/platform-api/internal/service/artifact_import_llm_provider.go index 2eba8bf7d..bb1c83b49 100644 --- a/platform-api/internal/service/artifact_import_llm_provider.go +++ b/platform-api/internal/service/artifact_import_llm_provider.go @@ -175,6 +175,6 @@ func mapLLMProviderSpecToConfig(spec dto.LLMProviderDeploymentSpec) model.LLMPro // single endpoint to the main endpoint. Returns nil when no upstream is present. func mapLLMUpstreamYAMLToModel(in dto.LLMUpstreamYAML) *model.UpstreamConfig { endpoint := &model.UpstreamEndpoint{URL: in.URL, Ref: in.Ref} - endpoint.Auth = mapUpstreamAuthAPIToModel(in.Auth) + endpoint.Auth = defaultUpstreamAuthToNone(mapUpstreamAuthAPIToModel(in.Auth)) return &model.UpstreamConfig{Main: endpoint} } diff --git a/platform-api/internal/service/artifact_import_llm_proxy.go b/platform-api/internal/service/artifact_import_llm_proxy.go index 47a1d2058..0d3077efe 100644 --- a/platform-api/internal/service/artifact_import_llm_proxy.go +++ b/platform-api/internal/service/artifact_import_llm_proxy.go @@ -171,9 +171,7 @@ func mapLLMProxySpecToConfig(spec dto.LLMProxyDeploymentSpec) model.LLMProxyConf vhost := spec.VHost cfg.Vhost = &vhost } - if spec.Provider.Auth != nil { - cfg.UpstreamAuth = mapUpstreamAuthAPIToModel(spec.Provider.Auth) - } + cfg.UpstreamAuth = defaultUpstreamAuthToNone(mapUpstreamAuthAPIToModel(spec.Provider.Auth)) // Security/rate-limiting are pushed as global (api-key-auth, api-level limits) and // operation (resource-scoped limits) policies by the forward conversion; older gateways // may still push legacy policies, so lift from all three. diff --git a/platform-api/internal/service/llm.go b/platform-api/internal/service/llm.go index fdcb5ebc5..7a7f02374 100644 --- a/platform-api/internal/service/llm.go +++ b/platform-api/internal/service/llm.go @@ -940,6 +940,13 @@ func (s *LLMProviderService) Create(orgUUID, createdBy string, req *api.LLMProvi } migrateLegacyProviderPoliciesInPlace(&m.Configuration) + if m.Configuration.Upstream != nil && m.Configuration.Upstream.Main != nil { + m.Configuration.Upstream.Main.Auth = defaultUpstreamAuthToNone(m.Configuration.Upstream.Main.Auth) + } + if m.Configuration.Upstream != nil && m.Configuration.Upstream.Sandbox != nil { + m.Configuration.Upstream.Sandbox.Auth = defaultUpstreamAuthToNone(m.Configuration.Upstream.Sandbox.Auth) + } + if err := s.repo.Create(m); err != nil { if isSQLiteUniqueConstraint(err) { return nil, apperror.LLMProviderExists.New() @@ -1143,6 +1150,13 @@ func (s *LLMProviderService) Update(orgUUID, handle, updatedBy string, req *api. m.Configuration = existing.Configuration } + if m.Configuration.Upstream != nil && m.Configuration.Upstream.Main != nil { + m.Configuration.Upstream.Main.Auth = defaultUpstreamAuthToNone(m.Configuration.Upstream.Main.Auth) + } + if m.Configuration.Upstream != nil && m.Configuration.Upstream.Sandbox != nil { + m.Configuration.Upstream.Sandbox.Auth = defaultUpstreamAuthToNone(m.Configuration.Upstream.Sandbox.Auth) + } + // Gateway associations are managed only when the field is present in the request. An // omitted field leaves associations untouched; an explicit (possibly empty) list // replaces the full set, removing any mapping no longer listed. Deployment state is not @@ -1166,7 +1180,10 @@ func (s *LLMProviderService) Update(orgUUID, handle, updatedBy string, req *api. // Best-effort: delete the secret the credential was rotated away from. Must // run after the update above persists the new reference, so the in-use // check below no longer sees this provider pointing at the old handle. - if s.secretService != nil { + // + // Skip when switching to a credential-less type ("none"/"other"): the credential + // is dropped from this artifact + if s.secretService != nil && !isCredentialLessUpstreamAuthType(mainUpstreamAuthType(m.Configuration.Upstream)) { s.secretService.cleanupRotatedSecret( orgUUID, mainUpstreamAuthValue(existing.Configuration.Upstream), @@ -1414,6 +1431,8 @@ func (s *LLMProxyService) Create(orgUUID, createdBy string, req *api.LLMProxy) ( } migrateLegacyProxyPoliciesInPlace(&m.Configuration) + m.Configuration.UpstreamAuth = defaultUpstreamAuthToNone(m.Configuration.UpstreamAuth) + if err := s.repo.Create(m); err != nil { if isSQLiteUniqueConstraint(err) { return nil, apperror.LLMProxyExists.New() @@ -1671,7 +1690,9 @@ func (s *LLMProxyService) Update(orgUUID, handle, updatedBy string, req *api.LLM } migrateLegacyProxyPoliciesInPlace(&m.Configuration) - // Preserve stored upstream auth credential when not supplied in update payload + // Preserve stored upstream auth credential only when the update provides an auth + // object with an empty value. If the auth object is omitted, treat it as explicit + // removal and clear stored auth (defaulted to "none" below). m.Configuration.UpstreamAuth = preserveUpstreamAuthCredential(existing.Configuration.UpstreamAuth, m.Configuration.UpstreamAuth) // The gateway owns the runtime configuration of a DP-originated (gateway_api) proxy, @@ -1686,6 +1707,8 @@ func (s *LLMProxyService) Update(orgUUID, handle, updatedBy string, req *api.LLM m.Configuration = existing.Configuration } + m.Configuration.UpstreamAuth = defaultUpstreamAuthToNone(m.Configuration.UpstreamAuth) + // Gateway associations are managed only when the field is present in the request. An // omitted field leaves associations untouched; an explicit (possibly empty) list // replaces the full set, removing any mapping no longer listed. Deployment state is not @@ -1709,7 +1732,10 @@ func (s *LLMProxyService) Update(orgUUID, handle, updatedBy string, req *api.LLM // Best-effort: delete the secret the credential was rotated away from. Must // run after the update above persists the new reference, so the in-use // check below no longer sees this proxy pointing at the old handle. - if s.secretService != nil { + // + // Skip when switching to a credential-less type ("none"/"other"): the credential + // is dropped from this artifact. + if s.secretService != nil && !isCredentialLessUpstreamAuthType(upstreamAuthType(m.Configuration.UpstreamAuth)) { s.secretService.cleanupRotatedSecret( orgUUID, upstreamAuthValue(existing.Configuration.UpstreamAuth), @@ -1831,7 +1857,7 @@ func preserveUpstreamAuthValue(existing, updated *model.UpstreamConfig) *model.U func preserveUpstreamAuthCredential(existing, updated *model.UpstreamAuth) *model.UpstreamAuth { if updated == nil { - return existing + return nil } if existing == nil { return updated @@ -2217,6 +2243,8 @@ func normalizeUpstreamAuthType(authType string) string { return string(api.Bearer) case "other": return string(api.Other) + case "none": + return string(api.None) default: return normalized } @@ -3293,3 +3321,47 @@ func resolveTemplateOpenAPISpec(ctx context.Context, tpl *model.LLMProviderTempl } return "" } + +// defaultUpstreamAuthToNone applies the platform's default upstream auth type and +// strips credentials from credential-less types. +func defaultUpstreamAuthToNone(auth *model.UpstreamAuth) *model.UpstreamAuth { + if auth == nil { + return &model.UpstreamAuth{Type: string(api.None)} + } + if strings.TrimSpace(auth.Type) == "" { + auth.Type = string(api.None) + } + if isCredentialLessUpstreamAuthType(auth.Type) { + auth.Header = "" + auth.Value = "" + } + return auth +} + +// isCredentialLessUpstreamAuthType reports whether an upstream auth type carries no +// credentials ("none" or "other"), in which case header/value are irrelevant. +func isCredentialLessUpstreamAuthType(authType string) bool { + switch normalizeUpstreamAuthType(authType) { + case string(api.None), string(api.Other): + return true + default: + return false + } +} + +// mainUpstreamAuthType nil-safely reads upstream.main.auth.type from an +// UpstreamConfig, returning "" when any part of the chain is nil. +func mainUpstreamAuthType(cfg *model.UpstreamConfig) string { + if cfg == nil || cfg.Main == nil || cfg.Main.Auth == nil { + return "" + } + return cfg.Main.Auth.Type +} + +// upstreamAuthType nil-safely reads .Type from an UpstreamAuth. +func upstreamAuthType(auth *model.UpstreamAuth) string { + if auth == nil { + return "" + } + return auth.Type +} diff --git a/platform-api/internal/service/llm_deployment.go b/platform-api/internal/service/llm_deployment.go index 82a4e45ec..442783e4d 100644 --- a/platform-api/internal/service/llm_deployment.go +++ b/platform-api/internal/service/llm_deployment.go @@ -1104,12 +1104,9 @@ func generateLLMProviderDeploymentYAML(provider *model.LLMProvider, templateHand policies = orderLLMPolicies(policies) upstream := dto.LLMUpstreamYAML{URL: main.URL, Ref: main.Ref} - // "other" means auth to the upstream is handled entirely by user-attached - // policies (e.g. aws-authentication) - omit the auth block from the deployment - // artifact so the gateway does not attach a header-setting policy of its own. - if main.Auth != nil && normalizeUpstreamAuthType(main.Auth.Type) != "other" { - upstream.Auth = mapModelAuthToAPI(main.Auth) - } + // Auth type. "none"/"other" carry only the type (no credentials); + // "api-key" carries the header and value. Absent auth => "none". + upstream.Auth = mapModelAuthToAPI(main.Auth) providerDeployment := dto.LLMProviderDeploymentYAML{ ApiVersion: constants.GatewayApiVersion, @@ -1878,12 +1875,9 @@ func generateLLMProxyDeploymentYAML(proxy *model.LLMProxy) (dto.LLMProxyDeployme }, } - // "other" means auth to the upstream is handled entirely by user-attached - // policies - omit the auth block from the deployment artifact so the gateway - // does not attach a header-setting policy of its own. - if proxy.Configuration.UpstreamAuth != nil && normalizeUpstreamAuthType(proxy.Configuration.UpstreamAuth.Type) != "other" { - proxyDeployment.Spec.Provider.Auth = mapModelUpstreamAuthToAPI(proxy.Configuration.UpstreamAuth) - } + // Auth type. "none"/"other" carry only the type (no credentials); + // "api-key" carries the header and value. Absent auth => "none". + proxyDeployment.Spec.Provider.Auth = mapModelAuthToAPI(proxy.Configuration.UpstreamAuth) // Carry additional providers (multi-provider proxies) into the deployment // artifact so the gateway-controller can expose each as a selectable upstream. @@ -1930,28 +1924,30 @@ func generateLLMProxyDeploymentYAML(proxy *model.LLMProxy) (dto.LLMProxyDeployme return proxyDeployment, nil } -// mapModelAuthToAPI converts model.UpstreamAuth to api.UpstreamAuth with pointer fields +// mapModelAuthToAPI converts a stored model.UpstreamAuth into the api.UpstreamAuth +// The gateway accepts an explicit type of "api-key", "other", or "none" +// absent/empty auth defaults to "none", and the credential-less types ("none"/"other") carry only +// the type - no header/value. "api-key" (and legacy basic/bearer) carry the header and value. func mapModelAuthToAPI(auth *model.UpstreamAuth) *api.UpstreamAuth { if auth == nil { - return nil + t := api.None + return &api.UpstreamAuth{Type: &t} } - var authType *api.UpstreamAuthType + authType := string(api.None) if normalized := normalizeUpstreamAuthType(auth.Type); normalized != "" { - t := api.UpstreamAuthType(normalized) - authType = &t + authType = normalized + } + t := api.UpstreamAuthType(authType) + if isCredentialLessUpstreamAuthType(authType) { + return &api.UpstreamAuth{Type: &t} } return &api.UpstreamAuth{ - Type: authType, + Type: &t, Header: utils.StringPtrIfNotEmpty(auth.Header), Value: utils.StringPtrIfNotEmpty(auth.Value), } } -// mapModelUpstreamAuthToAPI converts model.UpstreamAuth to api.UpstreamAuth (alias for mapModelAuthToAPI) -func mapModelUpstreamAuthToAPI(auth *model.UpstreamAuth) *api.UpstreamAuth { - return mapModelAuthToAPI(auth) -} - // orderLLMPolicies ensures llm-cost-based-ratelimit always precedes llm-cost in the policy list. // All other policies retain their relative positions. func orderLLMPolicies(policies []api.LLMPolicy) []api.LLMPolicy { diff --git a/platform-api/internal/service/llm_deployment_test.go b/platform-api/internal/service/llm_deployment_test.go index 7c583d0bf..a272c0a36 100644 --- a/platform-api/internal/service/llm_deployment_test.go +++ b/platform-api/internal/service/llm_deployment_test.go @@ -22,11 +22,11 @@ func TestMapModelAuthToAPI_NormalizesApiKeyType(t *testing.T) { func float32Ptr(f float32) *float32 { return &f } -// TestGenerateLLMProviderDeploymentYAML_OtherAuthOmitsUpstreamAuth verifies that when the -// upstream auth type is "other", the deployment artifact sent to the gateway omits the auth -// block entirely - the gateway must never see a credential-less "other" auth type; it must -// see no auth block at all, exactly as if auth were never configured. -func TestGenerateLLMProviderDeploymentYAML_OtherAuthOmitsUpstreamAuth(t *testing.T) { +// TestGenerateLLMProviderDeploymentYAML_OtherAuthEmitsTypeOnly verifies that when the +// upstream auth type is "other", the deployment artifact emits an explicit auth block that +// carries only the type - no header/value. Authentication is handled by user-attached +// policies, so the gateway attaches no header-setting policy of its own. +func TestGenerateLLMProviderDeploymentYAML_OtherAuthEmitsTypeOnly(t *testing.T) { provider := &model.LLMProvider{ ID: "test-provider", Name: "Test Provider", @@ -47,46 +47,79 @@ func TestGenerateLLMProviderDeploymentYAML_OtherAuthOmitsUpstreamAuth(t *testing if err != nil { t.Fatalf("unexpected error: %v", err) } - if yamlArtifact.Spec.Upstream.Auth != nil { - t.Fatalf("expected upstream auth to be omitted for auth type 'other', got %+v", yamlArtifact.Spec.Upstream.Auth) + auth := yamlArtifact.Spec.Upstream.Auth + if auth == nil || auth.Type == nil || *auth.Type != "other" { + t.Fatalf("expected upstream auth type 'other', got %+v", auth) } - - out, err := yaml.Marshal(yamlArtifact) - if err != nil { - t.Fatalf("unexpected marshal error: %v", err) - } - if strings.Contains(string(out), "auth:") { - t.Fatalf("expected marshaled YAML to omit the auth block entirely, got:\n%s", out) + if auth.Header != nil || auth.Value != nil { + t.Fatalf("expected no header/value for 'other', got header=%v value=%v", auth.Header, auth.Value) } } -// TestGenerateLLMProxyDeploymentYAML_OtherAuthOmitsProviderAuth is the LLM Proxy counterpart -// of TestGenerateLLMProviderDeploymentYAML_OtherAuthOmitsUpstreamAuth. -func TestGenerateLLMProxyDeploymentYAML_OtherAuthOmitsProviderAuth(t *testing.T) { - proxy := &model.LLMProxy{ - ID: "test-proxy", - Name: "Test Proxy", +// TestGenerateLLMProviderDeploymentYAML_NoAuthDefaultsToNone verifies that a provider with no +// configured upstream auth deploys with an explicit auth type of "none". +func TestGenerateLLMProviderDeploymentYAML_NoAuthDefaultsToNone(t *testing.T) { + provider := &model.LLMProvider{ + ID: "test-provider", + Name: "Test Provider", Version: "v1.0", - Configuration: model.LLMProxyConfig{ - Provider: "test-provider", - UpstreamAuth: &model.UpstreamAuth{Type: "other"}, + Configuration: model.LLMProviderConfig{ + Context: strPtr("/test"), + Upstream: &model.UpstreamConfig{ + Main: &model.UpstreamEndpoint{URL: "https://api.anthropic.com"}, + }, + AccessControl: &model.LLMAccessControl{Mode: "allow_all"}, }, } - yamlArtifact, err := generateLLMProxyDeploymentYAML(proxy) + yamlArtifact, err := generateLLMProviderDeploymentYAML(provider, "anthropic") if err != nil { t.Fatalf("unexpected error: %v", err) } - if yamlArtifact.Spec.Provider.Auth != nil { - t.Fatalf("expected provider auth to be omitted for auth type 'other', got %+v", yamlArtifact.Spec.Provider.Auth) + auth := yamlArtifact.Spec.Upstream.Auth + if auth == nil || auth.Type == nil || *auth.Type != "none" { + t.Fatalf("expected upstream auth type 'none' when no auth configured, got %+v", auth) } - - out, err := yaml.Marshal(yamlArtifact) - if err != nil { - t.Fatalf("unexpected marshal error: %v", err) + if auth.Header != nil || auth.Value != nil { + t.Fatalf("expected no header/value for 'none', got header=%v value=%v", auth.Header, auth.Value) } - if strings.Contains(string(out), "auth:") { - t.Fatalf("expected marshaled YAML to omit the auth block entirely, got:\n%s", out) +} + +// TestGenerateLLMProxyDeploymentYAML_OtherAndNoneEmitTypeOnly is the LLM Proxy counterpart: +// "other" and absent auth (=> "none") both emit an explicit, credential-less type. +func TestGenerateLLMProxyDeploymentYAML_OtherAndNoneEmitTypeOnly(t *testing.T) { + cases := []struct { + name string + auth *model.UpstreamAuth + wantType string + }{ + {name: "other", auth: &model.UpstreamAuth{Type: "other"}, wantType: "other"}, + {name: "absent defaults to none", auth: nil, wantType: "none"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + proxy := &model.LLMProxy{ + ID: "test-proxy", + Name: "Test Proxy", + Version: "v1.0", + Configuration: model.LLMProxyConfig{ + Provider: "test-provider", + UpstreamAuth: tc.auth, + }, + } + + yamlArtifact, err := generateLLMProxyDeploymentYAML(proxy) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + auth := yamlArtifact.Spec.Provider.Auth + if auth == nil || auth.Type == nil || string(*auth.Type) != tc.wantType { + t.Fatalf("expected provider auth type %q, got %+v", tc.wantType, auth) + } + if auth.Header != nil || auth.Value != nil { + t.Fatalf("expected no header/value for %q, got header=%v value=%v", tc.wantType, auth.Header, auth.Value) + } + }) } } diff --git a/platform-api/internal/service/llm_test.go b/platform-api/internal/service/llm_test.go index b3dfc0e59..6657ce85a 100644 --- a/platform-api/internal/service/llm_test.go +++ b/platform-api/internal/service/llm_test.go @@ -76,6 +76,8 @@ func TestNormalizeUpstreamAuthType(t *testing.T) { {name: "basic", input: "basic", expected: "basic"}, {name: "bearer", input: "bearer", expected: "bearer"}, {name: "other", input: "other", expected: "other"}, + {name: "none", input: "none", expected: "none"}, + {name: "none upper", input: "NONE", expected: "none"}, {name: "unknown preserved", input: "custom", expected: "custom"}, {name: "empty", input: "", expected: ""}, } @@ -90,6 +92,48 @@ func TestNormalizeUpstreamAuthType(t *testing.T) { } } +// TestMapLLMUpstreamYAMLToModel_DefaultsToNone verifies the DP->CP import default: +// a gateway-pushed provider whose upstream.auth block is absent (or empty-typed) +// is stored with auth type "none", while an explicit "other" type is preserved. +func TestMapLLMUpstreamYAMLToModel_DefaultsToNone(t *testing.T) { + // No auth block => "none". + got := mapLLMUpstreamYAMLToModel(dto.LLMUpstreamYAML{URL: "https://api.openai.com/v1"}) + if got == nil || got.Main == nil || got.Main.Auth == nil || got.Main.Auth.Type != "none" { + t.Fatalf("expected auth type 'none' for absent auth, got %+v", got) + } + + // Explicit "other" is preserved. + otherType := api.Other + got = mapLLMUpstreamYAMLToModel(dto.LLMUpstreamYAML{ + URL: "https://api.openai.com/v1", + Auth: &api.UpstreamAuth{Type: &otherType}, + }) + if got == nil || got.Main == nil || got.Main.Auth == nil || got.Main.Auth.Type != "other" { + t.Fatalf("expected auth type 'other' preserved, got %+v", got) + } +} + +// TestMapUpstreamConfigToDTO_ReturnsAuthAsIs verifies the read (GET) path returns the stored +// upstream config as-is: no auth block is synthesised when none is stored, and a stored type +// is returned unchanged. +func TestMapUpstreamConfigToDTO_ReturnsAuthAsIs(t *testing.T) { + // No stored auth -> no auth block in the response (not defaulted to "none"). + out := mapUpstreamConfigToDTO(&model.UpstreamConfig{ + Main: &model.UpstreamEndpoint{URL: "https://api.openai.com/v1"}, + }) + if out.Main.Auth != nil { + t.Fatalf("expected no auth block for stored nil auth, got %+v", out.Main.Auth) + } + + // A stored explicit type is returned unchanged. + out = mapUpstreamConfigToDTO(&model.UpstreamConfig{ + Main: &model.UpstreamEndpoint{URL: "https://api.openai.com/v1", Auth: &model.UpstreamAuth{Type: "none"}}, + }) + if out.Main.Auth == nil || out.Main.Auth.Type == nil || string(*out.Main.Auth.Type) != "none" { + t.Fatalf("expected stored auth type 'none' returned as-is, got %+v", out.Main.Auth) + } +} + func TestMapUpstreamAuthAPIToModel_NormalizesApiKeyType(t *testing.T) { authType := api.UpstreamAuthType("apiKey") in := &api.UpstreamAuth{ diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index fb2196d49..bb184bb3d 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -6718,7 +6718,7 @@ components: type: type: string description: Authentication type - enum: [ basic, bearer, api-key, other ] + enum: [ basic, bearer, api-key, other, none ] example: api-key header: type: string diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/ProviderTemplateFormFields.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/ProviderTemplateFormFields.tsx index 2d45dcd39..cfa7737d3 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/ProviderTemplateFormFields.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/ProviderTemplateFormFields.tsx @@ -116,6 +116,9 @@ export default function ProviderTemplateFormFields({ const hasTemplateAuthType = Boolean(template?.metadata?.auth?.type); const hasTemplateAuthHeader = Boolean(template?.metadata?.auth?.header); const isOtherAuthType = formState.upstreamAuthType === 'other'; + const isNoCredentialsAuthType = + formState.upstreamAuthType === 'other' || + formState.upstreamAuthType === 'none'; const trimmedVersion = formState.version.trim(); const versionErrorMessage = !versionTouched ? '' @@ -281,17 +284,17 @@ export default function ProviderTemplateFormFields({ value={formState.upstreamAuthType} onChange={(e) => { const nextAuthType = e.target.value; + const noCredentials = + nextAuthType === 'other' || nextAuthType === 'none'; setFormState((prev) => ({ ...prev, upstreamAuthType: nextAuthType, - upstreamAuthHeader: - nextAuthType === 'other' ? '' : prev.upstreamAuthHeader, - upstreamAuthValue: - nextAuthType === 'other' ? '' : prev.upstreamAuthValue, + upstreamAuthHeader: noCredentials ? '' : prev.upstreamAuthHeader, + upstreamAuthValue: noCredentials ? '' : prev.upstreamAuthValue, })); }} > - {['api-key', 'other'].map((type) => ( + {['api-key', 'other', 'none'].map((type) => ( {type} @@ -311,8 +314,8 @@ export default function ProviderTemplateFormFields({ )} - {/* Auth Header - only show if not provided by template and auth type is not "other" */} - {!hasTemplateAuthHeader && !isOtherAuthType && ( + {/* Auth Header - only show if not provided by template and auth type stores credentials */} + {!hasTemplateAuthHeader && !isNoCredentialsAuthType && ( @@ -336,8 +339,8 @@ export default function ProviderTemplateFormFields({ )} - {/* Auth Value - shown unless auth type is "other"; optional, provider can be created without it */} - {!isOtherAuthType && ( + {/* Auth Value - shown only for credential-bearing auth types; optional, provider can be created without it */} + {!isNoCredentialsAuthType && ( diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderConnectionTab.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderConnectionTab.tsx index 34fe6cae6..fed7f5a90 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderConnectionTab.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderConnectionTab.tsx @@ -56,7 +56,9 @@ export default function ServiceProviderConnectionTab() { const showSnackbar = useAIWorkspaceSnackbar(); const isReadOnlyProvider = Boolean(provider?.readOnly); const isFormDisabled = isLoading || Boolean(error) || isReadOnlyProvider; - const isOtherAuthType = (authenticationType || 'api-key') === 'other'; + const effectiveAuthType = authenticationType || 'none'; + const isOtherAuthType = effectiveAuthType === 'other'; + const isNoCredentialsAuthType = effectiveAuthType === 'other' || effectiveAuthType === 'none'; const valuePrefix = useMemo(() => { return providerTemplate?.metadata?.auth?.valuePrefix || ''; @@ -98,7 +100,7 @@ export default function ServiceProviderConnectionTab() { useEffect(() => { if (!provider) return; setProviderEndpoint(provider.upstream?.main?.url || ''); - setAuthenticationType(provider.upstream?.main?.auth?.type || 'api-key'); + setAuthenticationType(provider.upstream?.main?.auth?.type || 'none'); setAuthenticationHeader(provider.upstream?.main?.auth?.header || ''); if (initializedProviderIdRef.current === provider.id) { @@ -153,7 +155,7 @@ export default function ServiceProviderConnectionTab() { const nextType = value.trim(); if (!nextType || nextType === (provider.upstream?.main?.auth?.type || '')) return; - const isOther = nextType === 'other'; + const isNoCredentials = nextType === 'other' || nextType === 'none'; try { const { status, @@ -171,8 +173,12 @@ export default function ServiceProviderConnectionTab() { url: provider.upstream?.main?.url || '', auth: { type: nextType, - header: isOther ? '' : provider.upstream?.main?.auth?.header || '', - value: isOther ? '' : provider.upstream?.main?.auth?.value || '', + header: isNoCredentials + ? '' + : provider.upstream?.main?.auth?.header || '', + value: isNoCredentials + ? '' + : provider.upstream?.main?.auth?.value || '', }, }, }, @@ -301,12 +307,12 @@ export default function ServiceProviderConnectionTab() { Authentication @@ -331,7 +338,7 @@ export default function ServiceProviderConnectionTab() { )} - {!isOtherAuthType && ( + {!isNoCredentialsAuthType && ( <> Authentication Header diff --git a/portals/ai-workspace/src/utils/types.ts b/portals/ai-workspace/src/utils/types.ts index 6eeb2813a..2c27c7412 100644 --- a/portals/ai-workspace/src/utils/types.ts +++ b/portals/ai-workspace/src/utils/types.ts @@ -192,7 +192,7 @@ export interface ModelProvider { * Authentication configuration for upstream */ export interface UpstreamAuth { - type: 'api-key' | 'oauth2' | 'basic' | 'other' | string; + type: 'api-key' | 'oauth2' | 'basic' | 'other' | 'none' | string; header?: string; valuePrefix?: string; value?: string;