diff --git a/doc/code/setup/1_configuration.ipynb b/doc/code/setup/1_configuration.ipynb index e72bc1cc83..5ca85fa948 100644 --- a/doc/code/setup/1_configuration.ipynb +++ b/doc/code/setup/1_configuration.ipynb @@ -212,7 +212,17 @@ ")\n", "```\n", "\n", - "`auth_mode` applies to the OpenAI targets, `AzureMLChatTarget`, and `PromptShieldTarget`. `AzureBlobStorageTarget` accepts the same `auth_mode=\"identity\"` to bypass its SAS token sources; when no SAS token is configured it still selects a credential automatically, and that fallback is deprecated on the same schedule.\n", + "Pass `auth_mode=\"none\"` for an OpenAI-compatible server that needs no key, such as a local Ollama or vLLM server. The target then reads no key (not even the API key environment variable or `OPENAI_ADDITIONAL_REQUEST_HEADERS`) and mints no token. The OpenAI SDK still requires a key, so the target sends the fixed placeholder `not-needed`, which such servers ignore. It raises a `ValueError` if you also pass an explicit `api_key`.\n", + "\n", + "```python\n", + "target = OpenAIChatTarget(\n", + " endpoint=\"http://127.0.0.1:11434/v1\",\n", + " model_name=\"llama3\",\n", + " auth_mode=\"none\",\n", + ")\n", + "```\n", + "\n", + "`auth_mode` applies to the OpenAI targets, `AzureMLChatTarget`, and `PromptShieldTarget`; `auth_mode=\"none\"` is for the OpenAI targets only. `AzureBlobStorageTarget` accepts the same `auth_mode=\"identity\"` to bypass its SAS token sources; when no SAS token is configured it still selects a credential automatically, and that fallback is deprecated on the same schedule.\n", "\n", "> **Migration note.** Earlier versions silently minted an Entra token when no key was found and the endpoint looked like an Azure host. That fallback still works, but it is deprecated and removed in 1.4.0 because it made an explicit `auth_mode=\"identity\"` indistinguishable from \"no key configured\". Pass `auth_mode=\"identity\"` instead (or a token provider as `api_key`, as the examples above do)." ] diff --git a/doc/code/setup/1_configuration.py b/doc/code/setup/1_configuration.py index 9fff0f6090..6b41dfdc65 100644 --- a/doc/code/setup/1_configuration.py +++ b/doc/code/setup/1_configuration.py @@ -122,7 +122,17 @@ # ) # ``` # -# `auth_mode` applies to the OpenAI targets, `AzureMLChatTarget`, and `PromptShieldTarget`. `AzureBlobStorageTarget` accepts the same `auth_mode="identity"` to bypass its SAS token sources; when no SAS token is configured it still selects a credential automatically, and that fallback is deprecated on the same schedule. +# Pass `auth_mode="none"` for an OpenAI-compatible server that needs no key, such as a local Ollama or vLLM server. The target then reads no key (not even the API key environment variable or `OPENAI_ADDITIONAL_REQUEST_HEADERS`) and mints no token. The OpenAI SDK still requires a key, so the target sends the fixed placeholder `not-needed`, which such servers ignore. It raises a `ValueError` if you also pass an explicit `api_key`. +# +# ```python +# target = OpenAIChatTarget( +# endpoint="http://127.0.0.1:11434/v1", +# model_name="llama3", +# auth_mode="none", +# ) +# ``` +# +# `auth_mode` applies to the OpenAI targets, `AzureMLChatTarget`, and `PromptShieldTarget`; `auth_mode="none"` is for the OpenAI targets only. `AzureBlobStorageTarget` accepts the same `auth_mode="identity"` to bypass its SAS token sources; when no SAS token is configured it still selects a credential automatically, and that fallback is deprecated on the same schedule. # # > **Migration note.** Earlier versions silently minted an Entra token when no key was found and the endpoint looked like an Azure host. That fallback still works, but it is deprecated and removed in 1.4.0 because it made an explicit `auth_mode="identity"` indistinguishable from "no key configured". Pass `auth_mode="identity"` instead (or a token provider as `api_key`, as the examples above do). diff --git a/frontend/src/components/Config/CreateTargetDialog.test.tsx b/frontend/src/components/Config/CreateTargetDialog.test.tsx index 056405e25a..2c7049f6ca 100644 --- a/frontend/src/components/Config/CreateTargetDialog.test.tsx +++ b/frontend/src/components/Config/CreateTargetDialog.test.tsx @@ -200,7 +200,7 @@ const TARGET_TYPES: TargetTypeListResponse = { }, ...OPENAI_COMMON_PARAMETERS, ], - supported_auth_modes: ["api_key", "identity"], + supported_auth_modes: ["api_key", "identity", "none"], description: "Facilitates multimodal (image and text) input and text output generation.", }, { @@ -1878,6 +1878,91 @@ describe("CreateTargetDialog", () => { }); }); + it("should submit no-key auth without an API key", async () => { + const onCreated = jest.fn(); + const user = userEvent.setup(); + mockedTargetsApi.createTarget.mockResolvedValue(makeTarget({ + target_registry_name: "local_chat", + target_type: "OpenAIChatTarget", + })); + + render( + + + + ); + + await selectTargetType("OpenAIChatTarget"); + + expect(screen.getByRole("region", { name: /selected target details/i })).toHaveTextContent( + "Supported authentication: API key or Microsoft Entra ID or No key", + ); + await user.type( + screen.getByPlaceholderText("https://your-resource.openai.azure.com/"), + "http://localhost:11434/v1", + ); + await user.type(screen.getByPlaceholderText("API key (stored in memory only)"), "sk-typed-before-switch"); + await user.click(screen.getByRole("radio", { name: /No key/ })); + + expect(screen.queryByPlaceholderText("API key (stored in memory only)")).not.toBeInTheDocument(); + + await user.click(screen.getByText("Create Target")); + + await waitFor(() => { + expect(mockedTargetsApi.createTarget).toHaveBeenCalledWith({ + type: "OpenAIChatTarget", + params: { + endpoint: "http://localhost:11434/v1", + }, + auth_mode: "none", + }); + expect(onCreated).toHaveBeenCalled(); + }); + }); + + it("should offer no-key auth only for target types that support it", async () => { + render( + + + + ); + + await selectTargetType("AzureMLChatTarget"); + + expect(screen.getByRole("radio", { name: /Identity-based/ })).toBeInTheDocument(); + expect(screen.queryByRole("radio", { name: /No key/ })).not.toBeInTheDocument(); + }); + + it("should return to API key auth when the next target type has no no-key option", async () => { + const user = userEvent.setup(); + render( + + + + ); + + await selectTargetType("OpenAIChatTarget"); + await user.click(screen.getByRole("radio", { name: /No key/ })); + await selectTargetType("AzureMLChatTarget"); + + expect(screen.getByRole("radio", { name: /API Key/ })).toBeChecked(); + expect(screen.getByPlaceholderText("API key (stored in memory only)")).toBeInTheDocument(); + }); + + it("should offer no-key auth for OpenAI targets when type metadata fails to load", async () => { + mockedTargetsApi.listTargetTypes.mockRejectedValueOnce(new Error("registry unavailable")); + render( + + + + ); + + expect(await screen.findByText(/Target details could not be loaded/)).toBeInTheDocument(); + await selectTargetType("OpenAIChatTarget"); + + expect(screen.getByRole("radio", { name: /No key/ })).toBeInTheDocument(); + }); + it("should warn the user when identity is selected for a non-Azure OpenAI endpoint", async () => { const user = userEvent.setup(); diff --git a/frontend/src/components/Config/CreateTargetDialog.tsx b/frontend/src/components/Config/CreateTargetDialog.tsx index 6f30edefbd..4c57a1236d 100644 --- a/frontend/src/components/Config/CreateTargetDialog.tsx +++ b/frontend/src/components/Config/CreateTargetDialog.tsx @@ -33,7 +33,7 @@ import { } from '@/components/Parameters/parameterForm' import { targetsApi } from '@/services/api' import { toApiError } from '@/services/errors' -import type { TargetInstance, TargetTypeEntry } from '@/types' +import type { TargetAuthMode, TargetInstance, TargetTypeEntry } from '@/types' import { targetIdentifierHash, targetModelName, @@ -70,6 +70,15 @@ const FALLBACK_IDENTITY_TARGET_TYPES = new Set([ 'AzureMLChatTarget', ]) +const FALLBACK_NO_KEY_TARGET_TYPES = new Set([ + 'OpenAIChatTarget', + 'OpenAICompletionTarget', + 'OpenAIImageTarget', + 'OpenAIVideoTarget', + 'OpenAITTSTarget', + 'OpenAIResponseTarget', +]) + const TARGET_DISPLAY_NAMES: Record = { AzureMLChatTarget: 'Azure Machine Learning chat', GitHubCopilotTarget: 'GitHub Copilot', @@ -122,9 +131,14 @@ const FALLBACK_TARGET_TYPE_ENTRIES: TargetTypeEntry[] = FALLBACK_TARGET_TYPES.ma } )) -type AuthMode = 'api_key' | 'identity' type TypeMetadataStatus = 'loading' | 'loaded' | 'error' +const AUTH_MODE_LABELS: Record = { + api_key: 'API key', + identity: 'Microsoft Entra ID', + none: 'No key', +} + function getTargetDisplayName(targetType: string): string { return TARGET_DISPLAY_NAMES[targetType] ?? targetType } @@ -136,9 +150,7 @@ return 'Supported authentication: GitHub token' const authModes = entry.supported_auth_modes if (authModes.length === 0) return null - const labels = authModes.map((mode) => ( - mode === 'identity' ? 'Microsoft Entra ID' : 'API key' - )) + const labels = authModes.map((mode) => AUTH_MODE_LABELS[mode]) return `Supported authentication: ${labels.join(' or ')}` } @@ -149,6 +161,10 @@ function defaultSupportsIdentity(targetType: string): boolean { return FALLBACK_IDENTITY_TARGET_TYPES.has(targetType) } +function defaultSupportsNoKey(targetType: string): boolean { + return FALLBACK_NO_KEY_TARGET_TYPES.has(targetType) +} + function getParameterLabel(name: string): string { return name .split('_') @@ -264,7 +280,7 @@ export default function CreateTargetDialog({ open, onClose, onCreated, existingT const [modelName, setModelName] = useState('') const [hasDifferentUnderlying, setHasDifferentUnderlying] = useState(false) const [underlyingModel, setUnderlyingModel] = useState('') - const [authMode, setAuthMode] = useState('api_key') + const [authMode, setAuthMode] = useState('api_key') const [apiKey, setApiKey] = useState('') const [parameterValues, setParameterValues] = useState>({}) const [submitting, setSubmitting] = useState(false) @@ -384,12 +400,16 @@ export default function CreateTargetDialog({ open, onClose, onCreated, existingT const supportsIdentity = targetTypeEntry ? targetTypeEntry.supported_auth_modes.includes('identity') : defaultSupportsIdentity(targetType) - const showAuthField = targetType !== '' && supportsIdentity + const supportsNoKey = targetTypeEntry + ? targetTypeEntry.supported_auth_modes.includes('none') + : defaultSupportsNoKey(targetType) + const showAuthField = targetType !== '' && (supportsIdentity || supportsNoKey) const isIdentity = showAuthField && authMode === 'identity' + const isNoKey = showAuthField && authMode === 'none' const endpointRequired = hasEndpointField const modelNameRequired = Boolean(modelNameParameter?.required) const underlyingModelRequired = Boolean(underlyingModelParameter?.required) - const apiKeyRequired = Boolean(apiKeyParameter?.required) && !isIdentity + const apiKeyRequired = Boolean(apiKeyParameter?.required) && !isIdentity && !isNoKey const identityEndpointError: string | null = (() => { if (!isIdentity || endpoint === '') return null if (isOpenAi && !isAzureOpenAiEndpoint(endpoint)) { @@ -580,7 +600,7 @@ export default function CreateTargetDialog({ open, onClose, onCreated, existingT } if (hasEndpointField && endpoint) params.endpoint = endpoint if (hasModelNameField && modelName) params.model_name = modelName - if (hasApiKeyField && !isIdentity && apiKey) params.api_key = apiKey + if (hasApiKeyField && !isIdentity && !isNoKey && apiKey) params.api_key = apiKey if ( (underlyingModelRequired || hasDifferentUnderlying) @@ -594,6 +614,7 @@ export default function CreateTargetDialog({ open, onClose, onCreated, existingT type: targetType, params, ...(isIdentity ? { auth_mode: 'identity' as const } : {}), + ...(isNoKey ? { auth_mode: 'none' as const } : {}), }) resetForm() @@ -662,7 +683,13 @@ export default function CreateTargetDialog({ open, onClose, onCreated, existingT const nextSupportsIdentity = nextEntry ? nextEntry.supported_auth_modes.includes('identity') : defaultSupportsIdentity(next) - if (!nextSupportsIdentity) { + const nextSupportsNoKey = nextEntry + ? nextEntry.supported_auth_modes.includes('none') + : defaultSupportsNoKey(next) + if ( + (authMode === 'identity' && !nextSupportsIdentity) + || (authMode === 'none' && !nextSupportsNoKey) + ) { setAuthMode('api_key') } }} @@ -978,8 +1005,11 @@ export default function CreateTargetDialog({ open, onClose, onCreated, existingT { - const next = data.value as AuthMode + const next = data.value as TargetAuthMode setAuthMode(next) + if (next === 'none') { + setApiKey('') + } if (next === 'identity') { setApiKey('') setParameterValues((current) => { @@ -993,7 +1023,12 @@ export default function CreateTargetDialog({ open, onClose, onCreated, existingT }} > - + {supportsIdentity && ( + + )} + {supportsNoKey && ( + + )} )} @@ -1006,7 +1041,7 @@ export default function CreateTargetDialog({ open, onClose, onCreated, existingT )} - {hasApiKeyField && !isIdentity && ( + {hasApiKeyField && !isIdentity && !isNoKey && ( - auth_mode?: 'api_key' | 'identity' + auth_mode?: TargetAuthMode } // --- Initializers --- @@ -700,10 +700,12 @@ export interface ConverterPreviewResponse { steps: ConverterPreviewStep[] } +export type TargetAuthMode = 'api_key' | 'identity' | 'none' + export interface TargetTypeEntry { target_type: string parameters: Parameter[] - supported_auth_modes: ('api_key' | 'identity')[] + supported_auth_modes: TargetAuthMode[] description?: string | null } diff --git a/pyrit/auth/openai_auth.py b/pyrit/auth/openai_auth.py index fccb5ad852..8f894ebf09 100644 --- a/pyrit/auth/openai_auth.py +++ b/pyrit/auth/openai_auth.py @@ -9,6 +9,10 @@ from pyrit.common.auth_mode import AuthMode from pyrit.common.deprecation import print_deprecation_message +# The OpenAI SDK requires a key, so a no-key target passes this fixed, non-secret value. +# Servers that need no key ignore the resulting Authorization header. +_NO_KEY_PLACEHOLDER = "not-needed" + def resolve_openai_auth( *, @@ -26,7 +30,9 @@ def resolve_openai_auth( api_key_environment_variable (str): Environment variable to use when ``api_key`` is not provided. auth_mode (AuthMode): ``"identity"`` authenticates with a Microsoft Entra ID token minted for the endpoint; it ignores the API key environment variable and rejects an explicit - ``api_key``. ``"api_key"`` (the default) resolves a token-provider callable, then an + ``api_key``. ``"none"`` is for endpoints that need no key: it reads no key, rejects an + explicit ``api_key`` and returns a fixed, non-secret placeholder because the OpenAI SDK + requires one. ``"api_key"`` (the default) resolves a token-provider callable, then an explicit key, then the environment variable, and finally falls back to Entra ID on a recognized Azure OpenAI endpoint. That last fallback is deprecated and is removed in 1.4.0, after which identity auth must be requested explicitly. @@ -35,11 +41,19 @@ def resolve_openai_auth( str | Callable[[], Awaitable[str]]: API key string or async-compatible token provider. Raises: - ValueError: If identity auth is requested alongside an explicit ``api_key``, if identity auth - is requested for an endpoint that is not a recognized Azure OpenAI endpoint, or if - ``"api_key"`` auth is requested and no key is available for an endpoint that is not a - recognized Azure OpenAI endpoint. + ValueError: If identity or no-key auth is requested alongside an explicit ``api_key``, if + identity auth is requested for an endpoint that is not a recognized Azure OpenAI + endpoint, or if ``"api_key"`` auth is requested and no key is available for an endpoint + that is not a recognized Azure OpenAI endpoint. """ + if auth_mode == "none": + if api_key is not None: + raise ValueError( + 'auth_mode="none" cannot be combined with an explicit api_key. Omit api_key for an endpoint ' + 'that needs no key, or pass auth_mode="api_key" to send the key you supplied.' + ) + return _NO_KEY_PLACEHOLDER + # Identity is an explicit caller choice, so it must never be silently downgraded to a key # that merely happens to be present in the environment. if auth_mode == "identity": diff --git a/pyrit/backend/README.md b/pyrit/backend/README.md index c914f20503..2211ac9f0f 100644 --- a/pyrit/backend/README.md +++ b/pyrit/backend/README.md @@ -330,7 +330,9 @@ API callers are trusted operators, so these are intentional exceptions: the deployment instead. - Some targets use this server's credentials when the caller gives no key: an API key from the server's environment, sent even to an endpoint the caller chose, or a token for the server's - Azure identity, sent to the Azure endpoint or container the caller chose. + Azure identity, sent to the Azure endpoint or container the caller chose. For an + OpenAI-compatible server that needs no key, set `auth_mode` to `none` so the target uses none + of these. - Registered targets, converters, and scorers are shared: any API caller can use them, whoever created them. - Prompt content is not filtered; it is adversarial test data by design. diff --git a/pyrit/backend/models/targets.py b/pyrit/backend/models/targets.py index 378d0bbed0..cf15964f6c 100644 --- a/pyrit/backend/models/targets.py +++ b/pyrit/backend/models/targets.py @@ -8,11 +8,10 @@ ``pyrit.models.catalog.target`` and should be imported from there directly. """ -from typing import Literal - from pydantic import BaseModel, Field, model_validator from pyrit.backend.models.common import MAX_ITEMS, REGISTRY_INSTANCE_NAME_PATTERN, IdentifierStr, PaginationInfo +from pyrit.common.auth_mode import AuthMode from pyrit.models import JSONValue, Parameter, TargetIdentifier from pyrit.models.catalog.target import TargetInstance from pyrit.models.component_spec import SourceInstanceSpec @@ -26,7 +25,7 @@ ] -def _default_auth_modes() -> list[Literal["api_key", "identity"]]: +def _default_auth_modes() -> list[AuthMode]: return ["api_key"] @@ -38,7 +37,7 @@ class TargetTypeEntry(BaseModel): default_factory=list, description="Constructor parameters for dynamic form generation", ) - supported_auth_modes: list[Literal["api_key", "identity"]] = Field( + supported_auth_modes: list[AuthMode] = Field( default_factory=_default_auth_modes, description="Authentication modes this target type supports", ) @@ -75,14 +74,15 @@ class CreateTargetRequest(BaseModel): params: dict[IdentifierStr, JSONValue] = Field( default_factory=dict, max_length=MAX_ITEMS, description="Target constructor parameters" ) - auth_mode: Literal["api_key", "identity"] = Field( + auth_mode: AuthMode = Field( "api_key", description=( "Authentication mode. 'api_key' uses the api_key in params (default). " "'identity' omits the key so the target authenticates itself via an ambient " "Azure identity (Entra ID token or DefaultAzureCredential); requires an Azure " "endpoint and is supported by OpenAI-family targets, AzureMLChatTarget, " - "AzureBlobStorageTarget, and PromptShieldTarget." + "AzureBlobStorageTarget, and PromptShieldTarget. 'none' reads no key and sends a fixed " + "placeholder, for OpenAI-compatible servers that need no key; supported by OpenAI-family targets." ), ) diff --git a/pyrit/backend/services/target_service.py b/pyrit/backend/services/target_service.py index 5fdefe022a..e490b477b3 100644 --- a/pyrit/backend/services/target_service.py +++ b/pyrit/backend/services/target_service.py @@ -319,7 +319,8 @@ async def create_target_async( ``params``, and for ``identity`` it confirms the target supports it and omits the api_key plus any registry-flagged identity-conflicting parameters so the target validates its own endpoint and authenticates - itself. The request-level ``auth_mode`` is authoritative and is forwarded + itself. For ``none`` it likewise confirms support and omits the api_key. + The request-level ``auth_mode`` is authoritative and is forwarded via ``get_auth_mode_parameters`` to targets that accept it, so the choice is explicit rather than inferred from a missing key. A target that uploads local files gets the server's upload folder, which callers cannot choose. @@ -342,8 +343,8 @@ async def create_target_async( Raises: ValueError: If the target type is not registered, ``params`` carries an - ``auth_mode`` that conflicts with the request-level choice, identity - auth is requested but unsupported by the target type, or a target that + ``auth_mode`` that conflicts with the request-level choice, identity or + no-key auth is requested but unsupported by the target type, or a target that uploads local files is requested with its upload folder or while the server configures none. Construction errors (unknown params, incompatible inner targets, unrecognized identity endpoints) are @@ -383,6 +384,11 @@ async def create_target_async( if parameter.identity_conflicting: params.pop(parameter.name, None) + if request.auth_mode == "none": + if "none" not in target_cls.supported_auth_modes: + raise ValueError(f"Target type '{request.type}' does not support no-key authentication.") + params.pop("api_key", None) + params.update(target_cls.get_auth_mode_parameters(auth_mode=request.auth_mode)) self._apply_upload_directory( target_type=request.type, target_cls=target_cls, params=params, upload_directory=upload_directory diff --git a/pyrit/common/auth_mode.py b/pyrit/common/auth_mode.py index afcad5db14..73f45d3c6e 100644 --- a/pyrit/common/auth_mode.py +++ b/pyrit/common/auth_mode.py @@ -28,6 +28,10 @@ #: an ambient Azure identity when no SAS token is configured, but that fallback is #: deprecated and is removed in 1.4.0, after which every component treats the two modes #: as disjoint. -AuthMode = Literal["api_key", "identity"] +#: +#: ``none`` is for endpoints that need no credential, such as a local OpenAI-compatible +#: server. The component reads no key and mints no token. Only components that list it in +#: ``supported_auth_modes`` accept it. +AuthMode = Literal["api_key", "identity", "none"] -AUTH_MODES: tuple[AuthMode, ...] = ("api_key", "identity") +AUTH_MODES: tuple[AuthMode, ...] = ("api_key", "identity", "none") diff --git a/pyrit/embedding/openai_text_embedding.py b/pyrit/embedding/openai_text_embedding.py index bc83860e32..1b09072632 100644 --- a/pyrit/embedding/openai_text_embedding.py +++ b/pyrit/embedding/openai_text_embedding.py @@ -53,14 +53,16 @@ def __init__( Defaults to OPENAI_EMBEDDING_MODEL environment variable. auth_mode: ``"identity"`` authenticates with a Microsoft Entra ID token minted for the endpoint; it ignores the API key environment variable and rejects an explicit - ``api_key``. ``"api_key"`` (the default) resolves a token-provider callable, then an + ``api_key``. ``"none"`` is for OpenAI-compatible endpoints that need no key: it reads no + key, rejects an explicit ``api_key`` and sends the placeholder ``not-needed``. + ``"api_key"`` (the default) resolves a token-provider callable, then an explicit key, then the environment variable. Raises: - ValueError: If identity auth is requested alongside an explicit ``api_key``, if identity - auth is requested for an endpoint that is not a recognized Azure OpenAI / AI Foundry - endpoint, or if ``"api_key"`` auth is requested and no key is available via parameter - or environment variable. + ValueError: If identity or no-key auth is requested alongside an explicit ``api_key``, if + identity auth is requested for an endpoint that is not a recognized Azure OpenAI / AI + Foundry endpoint, or if ``"api_key"`` auth is requested and no key is available via + parameter or environment variable. """ endpoint = default_values.get_required_value( env_var_name=self.ENDPOINT_URI_ENVIRONMENT_VARIABLE, passed_value=endpoint diff --git a/pyrit/models/identifiers/target_identifier.py b/pyrit/models/identifiers/target_identifier.py index 8e36c9b84b..56820a7dcc 100644 --- a/pyrit/models/identifiers/target_identifier.py +++ b/pyrit/models/identifiers/target_identifier.py @@ -5,10 +5,11 @@ from __future__ import annotations -from typing import Annotated, ClassVar, Literal +from typing import Annotated, ClassVar from pydantic import Field +from pyrit.common.auth_mode import AuthMode # noqa: TC001 (runtime-required by Pydantic field annotations) from pyrit.models.identifiers.component_identifier import ComponentIdentifier from pyrit.models.identifiers.evaluation_markers import Evaluate from pyrit.models.identifiers.param_markers import Param @@ -58,7 +59,7 @@ class TargetIdentifier(ComponentIdentifier): #: class attribute (not a constructor argument, not part of identity). Read by #: the registry so ``TargetMetadata`` can describe the class without building it. supported_auth_modes: Annotated[ - list[Literal["api_key", "identity"]] | None, + list[AuthMode] | None, Evaluate.Exclude(), Param.ClassAttr(attr_name="supported_auth_modes"), ] = None diff --git a/pyrit/prompt_target/azure_blob_storage_target.py b/pyrit/prompt_target/azure_blob_storage_target.py index 698199b6e4..b10754dfcc 100644 --- a/pyrit/prompt_target/azure_blob_storage_target.py +++ b/pyrit/prompt_target/azure_blob_storage_target.py @@ -109,7 +109,11 @@ def __init__( max_requests_per_minute (int, Optional): Maximum number of requests per minute. custom_configuration (TargetConfiguration, Optional): Override the default configuration for this target instance. Defaults to None. + + Raises: + ValueError: If ``auth_mode`` is set and is not one of ``supported_auth_modes``. """ + self._validate_auth_mode(auth_mode) self._blob_content_type: str = blob_content_type.value self._container_url: str = default_values.get_required_value( diff --git a/pyrit/prompt_target/azure_ml_chat_target.py b/pyrit/prompt_target/azure_ml_chat_target.py index 405ffb8769..98d8d8115f 100644 --- a/pyrit/prompt_target/azure_ml_chat_target.py +++ b/pyrit/prompt_target/azure_ml_chat_target.py @@ -117,7 +117,11 @@ def __init__( Note that the link above may not be comprehensive, and specific acceptable parameters may be model-dependent. If a model does not accept a certain parameter that is passed in, it will be skipped without throwing an error. + + Raises: + ValueError: If ``auth_mode`` is not one of ``supported_auth_modes``. """ + self._validate_auth_mode(auth_mode) endpoint_value = default_values.get_required_value( env_var_name=self.endpoint_uri_environment_variable, passed_value=endpoint ) diff --git a/pyrit/prompt_target/common/prompt_target.py b/pyrit/prompt_target/common/prompt_target.py index 50c8084a38..f1400d7abe 100644 --- a/pyrit/prompt_target/common/prompt_target.py +++ b/pyrit/prompt_target/common/prompt_target.py @@ -81,7 +81,8 @@ class PromptTarget(Identifiable): # ``supported_auth_modes`` lists the auth modes the create-target API accepts # for this type. Base default is api-key only; targets that can authenticate # via an ambient Azure identity (e.g. OpenAI, Azure ML, Azure Blob Storage, - # Prompt Shield) override this to add ``"identity"``. + # Prompt Shield) override this to add ``"identity"``, and OpenAI targets also + # add ``"none"`` for OpenAI-compatible servers that need no key. supported_auth_modes: ClassVar[tuple[AuthMode, ...]] = ("api_key",) # Targets that upload files from the machine running PyRIT name the constructor parameter that @@ -137,6 +138,23 @@ def get_auth_mode_parameters(cls, *, auth_mode: AuthMode) -> dict[str, object]: """ return {} + @classmethod + def _validate_auth_mode(cls, auth_mode: AuthMode | None) -> None: + """ + Reject an authentication mode this target type does not support. + + Args: + auth_mode (AuthMode | None): Authentication mode selected by the caller, or None for the default. + + Raises: + ValueError: If ``auth_mode`` is set and is not one of ``supported_auth_modes``. + """ + if auth_mode is not None and auth_mode not in cls.supported_auth_modes: + raise ValueError( + f"{cls.__name__} does not support auth_mode={auth_mode!r}. " + f"Use one of: {', '.join(cls.supported_auth_modes)}." + ) + def __init__( self, *, diff --git a/pyrit/prompt_target/openai/openai_target.py b/pyrit/prompt_target/openai/openai_target.py index 2f1f411132..d2d4cdadc8 100644 --- a/pyrit/prompt_target/openai/openai_target.py +++ b/pyrit/prompt_target/openai/openai_target.py @@ -60,8 +60,9 @@ class OpenAITarget(PromptTarget): _response_adapter: OpenAIResponseAdapter[Any] = OpenAIResponseAdapter() # OpenAI-family targets can mint an Entra ID token for a recognized Azure - # endpoint (see ``is_azure_openai_endpoint``), so they support both modes. - supported_auth_modes: ClassVar[tuple[AuthMode, ...]] = ("api_key", "identity") + # endpoint (see ``is_azure_openai_endpoint``), and OpenAI-compatible servers + # such as Ollama or vLLM may need no key at all. + supported_auth_modes: ClassVar[tuple[AuthMode, ...]] = ("api_key", "identity", "none") model_name_environment_variable: str endpoint_environment_variable: str @@ -119,9 +120,13 @@ def __init__( auth_mode (AuthMode, Optional): Explicitly selects how to authenticate. ``"identity"`` authenticates with a Microsoft Entra ID token minted for the endpoint; it ignores the API key environment variable, rejects an explicit ``api_key``, and requires a - recognized Azure OpenAI / AI Foundry endpoint. Defaults to ``"api_key"``, which - resolves the key as described above. - headers (str, Optional): Extra headers of the endpoint (JSON). + recognized Azure OpenAI / AI Foundry endpoint. ``"none"`` is for OpenAI-compatible + endpoints that need no key: it reads neither the API key nor the + ``OPENAI_ADDITIONAL_REQUEST_HEADERS`` environment variable, rejects an explicit + ``api_key``, and sends the non-secret placeholder ``not-needed`` because the OpenAI + SDK requires a key. Defaults to ``"api_key"``, which resolves the key as described above. + headers (str, Optional): Extra headers of the endpoint (JSON). Defaults to the + ``OPENAI_ADDITIONAL_REQUEST_HEADERS`` environment variable unless ``auth_mode`` is ``"none"``. max_requests_per_minute (int, Optional): Number of requests the target can handle per minute before hitting a rate limit. The number of requests sent to the target will be capped at the value provided. @@ -137,14 +142,20 @@ def __init__( Raises: ValueError: If identity auth is requested for an endpoint that is not a recognized - Azure OpenAI / AI Foundry endpoint, or if ``"api_key"`` auth is requested and no - key is available via parameter or environment variable. + Azure OpenAI / AI Foundry endpoint, if identity or no-key auth is requested with an + explicit ``api_key``, or if ``"api_key"`` auth is requested and no key is available + via parameter or environment variable. """ self._headers: dict[str, str] = {} self._httpx_client_kwargs = httpx_client_kwargs or {} + self._auth_mode = auth_mode - request_headers = default_values.get_non_required_value( - env_var_name=self.ADDITIONAL_REQUEST_HEADERS, passed_value=headers + request_headers = ( + headers + if auth_mode == "none" + else default_values.get_non_required_value( + env_var_name=self.ADDITIONAL_REQUEST_HEADERS, passed_value=headers + ) ) self._headers = self._parse_request_headers(request_headers) @@ -203,9 +214,10 @@ def get_reconstruction_parameters(self) -> dict[str, object]: **identifier.promoted_scalar_values(), "model_name": self._model_name, "endpoint": self._endpoint, - "api_key": self._api_key, - # Reuse the resolved key or token provider, not the original credential-selection mode. - "auth_mode": "api_key", + # Reuse the resolved key or token provider, not the original credential-selection mode. A no-key + # target stays no-key, so a copy that overrides api_key or headers can't pick up a server value. + "api_key": None if self._auth_mode == "none" else self._api_key, + "auth_mode": "none" if self._auth_mode == "none" else "api_key", "headers": json.dumps(self._headers), "max_requests_per_minute": self._max_requests_per_minute, "httpx_client_kwargs": dict(self._httpx_client_kwargs), diff --git a/pyrit/prompt_target/prompt_shield_target.py b/pyrit/prompt_target/prompt_shield_target.py index a7fa3e3d58..c56186ca82 100644 --- a/pyrit/prompt_target/prompt_shield_target.py +++ b/pyrit/prompt_target/prompt_shield_target.py @@ -104,11 +104,13 @@ def __init__( this target instance. Defaults to None. Raises: - ValueError: If the endpoint value is not provided, if identity auth is requested + ValueError: If ``auth_mode`` is not one of ``supported_auth_modes``, if the endpoint + value is not provided, if identity auth is requested alongside an explicit ``api_key``, if identity auth is requested for an endpoint that is not a recognized Azure Content Safety endpoint, or if no API key is available for an endpoint that is not a recognized Azure Content Safety endpoint. """ + self._validate_auth_mode(auth_mode) endpoint_value = default_values.get_required_value( env_var_name=self.ENDPOINT_URI_ENVIRONMENT_VARIABLE, passed_value=endpoint ) diff --git a/tests/unit/auth/test_openai_auth.py b/tests/unit/auth/test_openai_auth.py index 782cae27da..149ebb118d 100644 --- a/tests/unit/auth/test_openai_auth.py +++ b/tests/unit/auth/test_openai_auth.py @@ -161,3 +161,33 @@ def test_api_key_mode_error_names_the_identity_migration(): message = str(exc_info.value) assert 'auth_mode="identity"' in message assert API_KEY_ENV_VAR in message + + +def test_none_reads_no_key_and_mints_no_token(minted_provider): + """A no-key endpoint must receive neither the server's key nor an Entra token, even on an Azure host.""" + _, mock_auth = minted_provider + with patch.dict(os.environ, {API_KEY_ENV_VAR: "sk-SECRET-FROM-DOTENV"}): + resolved = resolve_openai_auth( + endpoint=AZURE_ENDPOINT, + api_key=None, + api_key_environment_variable=API_KEY_ENV_VAR, + auth_mode="none", + ) + + assert resolved == "not-needed" + mock_auth.assert_not_called() + + +@pytest.mark.parametrize( + "explicit_key", + ["sk-explicit", "", lambda: "caller-supplied-token"], + ids=["key_string", "empty_string", "token_provider"], +) +def test_none_with_explicit_api_key_raises(explicit_key): + with pytest.raises(ValueError, match='auth_mode="none" cannot be combined with an explicit api_key'): + resolve_openai_auth( + endpoint=NON_AZURE_ENDPOINT, + api_key=explicit_key, + api_key_environment_variable=API_KEY_ENV_VAR, + auth_mode="none", + ) diff --git a/tests/unit/backend/test_target_service.py b/tests/unit/backend/test_target_service.py index 5a17eb04f7..71d36d9315 100644 --- a/tests/unit/backend/test_target_service.py +++ b/tests/unit/backend/test_target_service.py @@ -256,8 +256,9 @@ async def test_types_include_declarative_auth_facts(self) -> None: result = await service.list_target_types_async() openai_entry = next(item for item in result.items if item.target_type == "OpenAIChatTarget") - assert "api_key" in openai_entry.supported_auth_modes - assert "identity" in openai_entry.supported_auth_modes + assert openai_entry.supported_auth_modes == ["api_key", "identity", "none"] + azure_ml_entry = next(item for item in result.items if item.target_type == "AzureMLChatTarget") + assert azure_ml_entry.supported_auth_modes == ["api_key", "identity"] async def test_types_include_github_copilot_form_parameters_async(self) -> None: service = TargetService() @@ -1511,3 +1512,40 @@ def test_required_target_parameters_match_frontend_form_capabilities(self) -> No "frontend/src/components/Config/targetParameterPolicy.ts with a renderable or " "explicitly unsupported policy, then update this expected catalog." ) + + +class TestCreateTargetNoKeyAuth: + """No-key auth at the service boundary: the service confirms support and drops any key.""" + + async def test_create_openai_target_with_no_key_reads_no_server_credentials(self, sqlite_instance) -> None: + with patch.dict( + os.environ, + {"OPENAI_CHAT_KEY": "server-key", "OPENAI_ADDITIONAL_REQUEST_HEADERS": '{"X-Server-Secret": "s"}'}, + ): + service = TargetService() + request = CreateTargetRequest( + type="OpenAIChatTarget", + params={ + "endpoint": "http://localhost:11434/v1", + "model_name": "llama3", + "api_key": "should-be-ignored", + }, + auth_mode="none", + ) + + result = await service.create_target_async(request=request) + + target_obj = service.get_target_object(target_registry_name=result.target_registry_name) + assert target_obj._api_key == "not-needed" # type: ignore[attr-defined] + assert target_obj._headers == {} # type: ignore[attr-defined] + + async def test_create_target_with_no_key_rejects_unsupported_type(self, sqlite_instance) -> None: + service = TargetService() + request = CreateTargetRequest( + type="AzureMLChatTarget", + params={"endpoint": "https://example.inference.ml.azure.com/score"}, + auth_mode="none", + ) + + with pytest.raises(ValueError, match="does not support no-key authentication"): + await service.create_target_async(request=request) diff --git a/tests/unit/backend/test_temporary_components.py b/tests/unit/backend/test_temporary_components.py index 74c2b7ccd9..1f8d4ae0f0 100644 --- a/tests/unit/backend/test_temporary_components.py +++ b/tests/unit/backend/test_temporary_components.py @@ -267,6 +267,36 @@ async def test_identity_source_reuses_resolved_token_provider_async(self) -> Non finally: await source.cleanup_target_async() + @pytest.mark.parametrize( + "overrides", + [ + {"temperature": 0.8}, + {"temperature": 0.8, "api_key": None}, + {"temperature": 0.8, "headers": None}, + {"temperature": 0.8, "headers": ""}, + ], + ) + async def test_no_key_source_stays_no_key_async(self, overrides: dict[str, Any]) -> None: + source = OpenAIChatTarget(endpoint="http://localhost:11434/v1", model_name="llama3", auth_mode="none") + registry = TargetRegistry.get_registry_singleton() + registry.instances.register(source, name="source") + try: + with patch.dict( + "os.environ", + {"OPENAI_CHAT_KEY": "server-key", "OPENAI_ADDITIONAL_REQUEST_HEADERS": '{"X-Server-Secret": "s"}'}, + ): + private = await TargetService().build_from_source_async( + SourceInstanceSpec(source_name="source", source_hash=source.get_identifier().hash, params=overrides) + ) + try: + assert private._api_key == "not-needed" + assert private._headers == {} + assert private.get_identifier().temperature == 0.8 + finally: + await private.cleanup_target_async() + finally: + await source.cleanup_target_async() + async def test_two_temperature_variants_are_isolated_async(self) -> None: source = source_target() TargetRegistry.get_registry_singleton().instances.register(source, name="source") diff --git a/tests/unit/prompt_target/target/test_openai_target_auth.py b/tests/unit/prompt_target/target/test_openai_target_auth.py index 934de96b8c..80634fc449 100644 --- a/tests/unit/prompt_target/target/test_openai_target_auth.py +++ b/tests/unit/prompt_target/target/test_openai_target_auth.py @@ -158,6 +158,26 @@ def test_identity_auth_mode_non_azure_endpoint_raises(self): auth_mode="identity", ) + def test_no_key_auth_mode_ignores_env_var_key_and_headers(self): + """A no-key target sends neither the server's key nor the server's extra request headers.""" + target = _build_target( + endpoint="http://localhost:11434/v1", + api_key=None, + env_vars={"TEST_API_KEY": "env-key", "OPENAI_ADDITIONAL_REQUEST_HEADERS": '{"X-Server-Secret": "s"}'}, + auth_mode="none", + ) + + assert target._api_key == "not-needed" + assert target._headers == {} + + def test_no_key_auth_mode_keeps_explicit_headers(self): + with patch.dict(os.environ, {"TEST_MODEL": "llama3"}, clear=True): + target = _ConcreteOpenAITarget( + endpoint="http://localhost:11434/v1", auth_mode="none", headers='{"X-Route": "local"}' + ) + + assert target._headers == {"X-Route": "local"} + class TestEnsureAsyncTokenProvider: """Tests for the ensure_async_token_provider helper function.""" diff --git a/tests/unit/prompt_target/target/test_prompt_target.py b/tests/unit/prompt_target/target/test_prompt_target.py index 3ea93d2c7e..8357007221 100644 --- a/tests/unit/prompt_target/target/test_prompt_target.py +++ b/tests/unit/prompt_target/target/test_prompt_target.py @@ -22,7 +22,7 @@ PromptDataType, flatten_to_message_pieces, ) -from pyrit.prompt_target import OpenAIChatTarget +from pyrit.prompt_target import AzureBlobStorageTarget, AzureMLChatTarget, OpenAIChatTarget, PromptShieldTarget from pyrit.prompt_target.common.target_capabilities import ( CapabilityHandlingPolicy, CapabilityName, @@ -984,3 +984,17 @@ def test_apply_capabilities_rebuilds_pipeline(patch_central_database): target.apply_capabilities(capabilities=TargetCapabilities(supports_multi_turn=True, supports_system_prompt=True)) assert not target.configuration.pipeline._normalizers, "Expected pipeline to be rebuilt as empty" + + +@pytest.mark.usefixtures("patch_central_database") +@pytest.mark.parametrize( + ("target_class", "kwargs"), + [ + (AzureMLChatTarget, {"endpoint": "https://example.inference.ml.azure.com/score", "api_key": "test-key"}), + (PromptShieldTarget, {"endpoint": "https://example.cognitiveservices.azure.com/", "api_key": "test-key"}), + (AzureBlobStorageTarget, {"container_url": "https://example.blob.core.windows.net/container"}), + ], +) +def test_targets_without_no_key_support_reject_none_auth_mode(target_class: type, kwargs: dict[str, Any]) -> None: + with pytest.raises(ValueError, match=f"{target_class.__name__} does not support auth_mode='none'"): + target_class(auth_mode="none", **kwargs) diff --git a/tests/unit/registry/test_target_registry.py b/tests/unit/registry/test_target_registry.py index ffc0fbe1b7..20589327ad 100644 --- a/tests/unit/registry/test_target_registry.py +++ b/tests/unit/registry/test_target_registry.py @@ -360,15 +360,15 @@ def test_round_robin_targets_param_is_reference(self, registry: TargetRegistry): assert any(p.is_reference_to(ComponentType.TARGET) for p in meta.parameters) def test_metadata_supported_auth_modes_projects_class_attribute(self, registry: TargetRegistry): - # Identity-capable targets expose both modes; api-key-only targets expose one. - assert self._metadata_for(registry, "OpenAIChatTarget").supported_auth_modes == ("api_key", "identity") + # OpenAI targets expose all three modes; api-key-only targets expose one. + assert self._metadata_for(registry, "OpenAIChatTarget").supported_auth_modes == ("api_key", "identity", "none") assert self._metadata_for(registry, "TextTarget").supported_auth_modes == ("api_key",) def test_metadata_supported_auth_modes_sourced_from_class_attributes(self, registry: TargetRegistry): # Auth modes are read off the class via Param.ClassAttr, not a fabricated instance. meta = self._metadata_for(registry, "OpenAIChatTarget") assert "supported_auth_modes" in meta.class_attributes - assert meta.class_attributes["supported_auth_modes"] == ("api_key", "identity") + assert meta.class_attributes["supported_auth_modes"] == ("api_key", "identity", "none") @pytest.mark.usefixtures("patch_central_database") def test_instance_registration_does_not_invalidate_class_metadata(self, registry: TargetRegistry) -> None: @@ -458,3 +458,13 @@ def test_identity_targets_accept_an_explicit_auth_mode(self, registry: TargetReg assert target_cls.get_auth_mode_parameters(auth_mode="identity") == {"auth_mode": "identity"}, ( f"{name} advertises identity support but does not forward an explicit auth_mode" ) + + def test_no_key_targets_accept_an_explicit_auth_mode(self, registry: TargetRegistry) -> None: + # A no-key target that dropped the explicit mode would fall back to reading a key variable. + for name in registry.get_class_names(): + target_cls = registry.get_class(name) + if "none" not in target_cls.supported_auth_modes: + continue + assert target_cls.get_auth_mode_parameters(auth_mode="none") == {"auth_mode": "none"}, ( + f"{name} advertises no-key support but does not forward an explicit auth_mode" + )