From 0e61db3922e9f114c1a27e28caba1daa31475f3e Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:36:57 -0500 Subject: [PATCH] resolve saved credential sentinel to the stored api_key in the GenAI probe --- docs/static/frigate-api.yaml | 5 ++ frigate/api/app.py | 11 ++- frigate/api/defs/request/app_body.py | 1 + frigate/test/http_api/test_http_app.py | 71 +++++++++++++++++++ .../theme/widgets/GenAIModelWidget.tsx | 1 + 5 files changed, 87 insertions(+), 2 deletions(-) diff --git a/docs/static/frigate-api.yaml b/docs/static/frigate-api.yaml index af3a019b4c..f8519c4b47 100644 --- a/docs/static/frigate-api.yaml +++ b/docs/static/frigate-api.yaml @@ -8244,6 +8244,11 @@ components: properties: provider: $ref: '#/components/schemas/GenAIProviderEnum' + name: + anyOf: + - type: string + - type: 'null' + title: Name api_key: anyOf: - type: string diff --git a/frigate/api/app.py b/frigate/api/app.py index 7f78f4b56c..142d4ee0e4 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -196,7 +196,7 @@ def genai_models(request: Request): "before saving the configuration." ), ) -async def genai_probe(body: GenAIProbeBody): +async def genai_probe(request: Request, body: GenAIProbeBody): load_providers() provider_cls = PROVIDERS.get(body.provider) @@ -206,6 +206,13 @@ async def genai_probe(body: GenAIProbeBody): content={"success": False, "message": "Unknown provider"}, ) + api_key = body.api_key + if api_key == REDACTED_CREDENTIAL_SENTINEL: + saved_cfg = ( + request.app.frigate_config.genai.get(body.name) if body.name else None + ) + api_key = saved_cfg.api_key if saved_cfg else None + # The OpenAI-compatible SDKs accept "timeout" as a constructor kwarg via # provider_options; other plugins use GenAIClient.timeout passed below. # Don't inject timeout for Gemini — its HttpOptions interprets the value @@ -217,7 +224,7 @@ async def genai_probe(body: GenAIProbeBody): try: transient_cfg = GenAIConfig( provider=body.provider, - api_key=body.api_key, + api_key=api_key, base_url=body.base_url, provider_options=probe_provider_options, # model is required by the schema but irrelevant for listing. diff --git a/frigate/api/defs/request/app_body.py b/frigate/api/defs/request/app_body.py index b0b85c7ab9..a331482703 100644 --- a/frigate/api/defs/request/app_body.py +++ b/frigate/api/defs/request/app_body.py @@ -14,6 +14,7 @@ class AppConfigSetBody(BaseModel): class GenAIProbeBody(BaseModel): provider: GenAIProviderEnum + name: str | None = None api_key: str | None = None base_url: str | None = None provider_options: dict[str, Any] = Field(default_factory=dict) diff --git a/frigate/test/http_api/test_http_app.py b/frigate/test/http_api/test_http_app.py index 4c581dd426..ef5b99ad08 100644 --- a/frigate/test/http_api/test_http_app.py +++ b/frigate/test/http_api/test_http_app.py @@ -132,6 +132,77 @@ class TestHttpApp(BaseTestHttp): "models": ["fake-model-a", "fake-model-b"], } + def test_genai_probe_resolves_sentinel_to_saved_api_key(self): + # After a save the UI's api_key field holds the redaction sentinel; + # the probe must substitute the saved key for the named entry instead + # of sending the literal sentinel to the provider (GH discussion 23754). + probed_keys: list[str | None] = [] + + class CapturingClient(GenAIClient): + def list_models(self): + probed_keys.append(self.genai_config.api_key) + return ["fake-model"] + + self.minimal_config["genai"] = { + "llm": { + "provider": "openai", + "api_key": "sk-saved", + "base_url": "https://example.invalid", + "model": "fake-model", + } + } + app = super().create_app() + + with ( + AuthTestClient(app) as client, + patch.dict( + frigate.genai.PROVIDERS, + {GenAIProviderEnum.openai: CapturingClient}, + ), + ): + response = client.post( + "/genai/probe", + json={ + "provider": "openai", + "name": "llm", + "api_key": REDACTED_CREDENTIAL_SENTINEL, + "base_url": "https://example.invalid", + }, + ) + assert response.status_code == 200 + assert response.json()["success"] is True + assert probed_keys == ["sk-saved"] + + def test_genai_probe_sentinel_without_saved_entry_sends_no_key(self): + # If the sentinel arrives for an entry that has no saved config, the + # probe must drop the key entirely rather than leak the sentinel. + probed_keys: list[str | None] = [] + + class CapturingClient(GenAIClient): + def list_models(self): + probed_keys.append(self.genai_config.api_key) + return ["fake-model"] + + app = super().create_app() + + with ( + AuthTestClient(app) as client, + patch.dict( + frigate.genai.PROVIDERS, + {GenAIProviderEnum.openai: CapturingClient}, + ), + ): + response = client.post( + "/genai/probe", + json={ + "provider": "openai", + "name": "llm", + "api_key": REDACTED_CREDENTIAL_SENTINEL, + }, + ) + assert response.status_code == 200 + assert probed_keys == [None] + def test_genai_probe_empty_list_is_treated_as_failure(self): # The plugin's list_models() returns [] on connection failure rather # than raising. The endpoint should surface that as success=false so diff --git a/web/src/components/config-form/theme/widgets/GenAIModelWidget.tsx b/web/src/components/config-form/theme/widgets/GenAIModelWidget.tsx index 58a30434b4..52fd3aa57c 100644 --- a/web/src/components/config-form/theme/widgets/GenAIModelWidget.tsx +++ b/web/src/components/config-form/theme/widgets/GenAIModelWidget.tsx @@ -160,6 +160,7 @@ export function GenAIModelWidget(props: WidgetProps) { try { const res = await axios.post("genai/probe", { provider: formProvider, + name: providerKey, api_key: typeof formEntry.api_key === "string" ? formEntry.api_key : null, base_url: