@@ -60,19 +60,19 @@ Self-hosted Tesla intelligence. Install it, connect a vehicle, then use the cata
## Operate and contribute
-
+
Release verification
What to check before you roll a build.
-
+
Secrets
Tokens, encryption, and rotation.
-
+
Contribute
Code structure, adding features, API notes.
-
+
FAQ
Requirements, Tesla constraints, what we do not promise.
diff --git a/docs/guide/helix-ai.md b/docs/guide/helix-ai.md
index f83bf59f61..96c2cd1205 100644
--- a/docs/guide/helix-ai.md
+++ b/docs/guide/helix-ai.md
@@ -143,10 +143,56 @@ plus a `mock` adapter for tests:
| Adapter | Use cases |
|---|---|
| `openai` | OpenAI hosted models (gpt-4o, gpt-4o-mini, gpt-4.1, …) |
-| `azure` | Azure OpenAI Service (`{resource}.openai.azure.com` + deployment path) **or** Azure AI Foundry OpenAI v1 (`{resource}.services.ai.azure.com/openai/v1`, model = deployment name). Pasting the Foundry portal endpoint auto-selects v1 routing so Validate no longer 404s. |
+| `azure` | Microsoft Foundry **OpenAI v1 only** (`{resource}.services.ai.azure.com/openai/v1` or `{resource}.openai.azure.com/openai/v1`). Select Auto, Chat Completions, or Responses. No model-name routing or older API surfaces. |
| `anthropic` | Claude models (Sonnet, Opus, Haiku, …) |
| `ollama` | Self-hosted models via [Ollama](https://ollama.com) — fully local |
+Set **Deployment name** to the exact portal deployment name. The optional
+**Embedding deployment name** is independent of the chat deployment. Both use
+the same Foundry v1 base URL and API key; the persisted provider key remains
+`azure`, so existing credentials and feature-provider selections are preserved.
+Resource-root URLs automatically receive `/openai/v1`; `/models`, deployment-path
+URLs, queries, and other API paths are rejected rather than routed elsewhere.
+
+**API protocol** defaults to **Auto**: Chat Completions first, then one Responses
+attempt only on structured operation-not-supported / not-found errors.
+**Chat Completions** and **Responses** call only the selected API, without fallback.
+The selection is persisted as `api_protocol` (`auto`, `chat_completions`, or
+`responses`) and is used by validation and Helix Chat/Stream identically.
+Embeddings always call `/openai/v1/embeddings`, regardless of chat protocol.
+
+**Existing configuration migration:** obsolete `flavor`, `api_version`,
+`deployment`, and `embedding_deployment` fields are not part of the adapter.
+For entries without `api_protocol`, the configuration-read boundary promotes a previously effective chat deployment
+override into `model` (a stale override from a Foundry-flavor entry
+is ignored, preserving its existing model identity). An embedding override is
+promoted into `embedding_model`. The settings form displays these effective
+names and removes obsolete keys on Save. Modern entries with `api_protocol`
+ignore obsolete override keys. No legacy routes or model-specific
+heuristics remain. Review the visible names and endpoint before saving.
+
+Validate uses a 30-second timeout and an explicit
+1,024-output-token Azure probe budget (including reasoning tokens). Normal
+requests keep their caller-supplied token budget.
+
+Auto makes at most two attempts and never changes the configured API base.
+Authentication, rate-limit, server, and token-budget failures do not
+trigger fallback; if both protocols fail, both errors are retained.
+The dispatcher does not replay a finalized Azure failure as another Chat call.
+Responses requests send `store: false` and replay tool calls/results, encrypted
+reasoning items, and assistant message phases explicitly between tool turns.
+Opaque continuation state is kept only in memory, not serialized into saved
+history or client responses. Existing content redaction still applies.
+Responses fallback streaming is **buffered**, not token-by-token SSE: Helix
+receives text/tool chunks followed by one terminal event after completion.
+Failed, incomplete, refused, and empty Responses results are errors, not success.
+Chat Completions also rejects malformed/empty completions and unfinished streams;
+truncated or filtered tool calls are never executed. Cancellation closes the
+upstream stream, including when the downstream client stops accepting output.
+
+Protocol references: [Azure Responses API](https://learn.microsoft.com/en-us/azure/foundry/openai/how-to/responses)
+and [Azure OpenAI v1 lifecycle](https://learn.microsoft.com/en-us/azure/foundry/openai/api-version-lifecycle).
+
### Per-feature provider selection
Provider choice resolves with this precedence (highest wins):
diff --git a/docs/guide/local-development.md b/docs/guide/local-development.md
index 96603f2bf9..f8370da903 100644
--- a/docs/guide/local-development.md
+++ b/docs/guide/local-development.md
@@ -178,6 +178,47 @@ The docs config (`docs/.vitepress/config.ts`) controls navigation, sidebar, and
The pre-PR baseline is "everything in this table passes". CI runs the same set.
+### CI parallel execution
+
+CI scales out across hosted runners rather than oversubscribing one machine:
+
+- Go race tests use eight disjoint package shards, balanced by test-source size.
+ Packages without tests are retained. Each shard has its own TimescaleDB service.
+- Vitest uses eight native shards with two workers per runner. The merge job
+ checks all shard artifacts against full test-file discovery before merging
+ Vitest's coverage maps. Go profiles merge atomic block counts, not percentages.
+- Lint, architecture checks, individual Go binary builds, frontend builds, and
+ database replay/rollback start independently of unit tests. Docker image builds
+ wait for generated-artifact, backend, and frontend gates to pass; failed tests
+ or incomplete coverage merges skip Docker rather than spending build compute.
+- Browser jobs reuse one hermetic build. Chromium responsive/smoke tests and
+ Windows visual snapshots each use four shards. Accessibility and performance
+ run on separate runners with one worker each; Firefox and WebKit remain independent.
+
+The existing **Backend (lint + test + build)**, **Frontend (lint + test + build)**,
+Chromium quality, and Windows snapshot check names remain aggregate gates. Failed,
+cancelled, skipped, or incomplete required shards cannot turn those gates green.
+`backend-coverage` and `frontend-coverage` still contain the merged reports; raw
+shard artifacts remain available for diagnosis. The pre-existing telemetry replay
+`continue-on-error` exception is unchanged; this parallelization adds no new waivers
+and does not change coverage thresholds.
+
+To reproduce a frontend shard locally from `web/`:
+
+```bash
+npx vitest run --shard=1/8 --maxWorkers=2 --coverage --coverage.reporter=json --reporter=blob
+```
+
+To reproduce a browser shard using the existing build/preview wrapper:
+
+```bash
+npm run e2e:quality -- --shard=1/4
+```
+
+Changes to shard counts must update the workflow matrix and corresponding count
+together. The report validators deliberately reject missing artifacts rather than
+publishing partial coverage as a complete run.
+
## Debugging tips
- **The backend isn't seeing my env change** — Go binaries snapshot the environment at startup. Restart the process.
diff --git a/docs/public/openapi.yaml b/docs/public/openapi.yaml
index 49bf036ea3..81e3d1e78e 100644
--- a/docs/public/openapi.yaml
+++ b/docs/public/openapi.yaml
@@ -1352,6 +1352,8 @@ paths:
example: 20
vehicle_id:
type: integer
+ channel_ids:
+ $ref: "#/components/schemas/AlertRuleChannels"
responses:
"201":
description: Alert rule created
@@ -1364,6 +1366,48 @@ paths:
"500":
$ref: "#/components/responses/InternalError"
+ /alerts/rules/bulk/delete:
+ post:
+ operationId: bulkDeleteAlertRules
+ summary: Delete explicitly selected alert rules
+ description: Deletes the specified rules and their pack memberships. Unknown IDs are ignored. Rate limited.
+ tags: [Alerts]
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: false
+ required: [ids]
+ properties:
+ ids:
+ type: array
+ minItems: 1
+ maxItems: 500
+ items:
+ type: integer
+ format: int64
+ minimum: 1
+ responses:
+ "200":
+ description: IDs of rules actually deleted
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [deleted_ids]
+ properties:
+ deleted_ids:
+ type: array
+ items:
+ type: integer
+ format: int64
+ "400":
+ $ref: "#/components/responses/BadRequest"
+ "500":
+ $ref: "#/components/responses/InternalError"
+
/alerts/rules/{ruleID}:
put:
operationId: updateAlertRule
@@ -1391,6 +1435,8 @@ paths:
type: number
enabled:
type: boolean
+ channel_ids:
+ $ref: "#/components/schemas/AlertRuleChannels"
responses:
"200":
description: Alert rule updated
@@ -6471,6 +6517,17 @@ components:
type: string
format: date-time
+ AlertRuleChannels:
+ type: array
+ nullable: true
+ maxItems: 100
+ uniqueItems: true
+ description: Null selects all enabled external channels, including future channels. An empty array disables external delivery. An explicit list selects only those channels. Browser notifications are unchanged. Omission during update preserves the current selection.
+ items:
+ type: integer
+ format: int64
+ minimum: 1
+
AlertRule:
type: object
properties:
@@ -6480,6 +6537,8 @@ components:
name:
type: string
example: Low Battery Alert
+ channel_ids:
+ $ref: "#/components/schemas/AlertRuleChannels"
type:
type: string
enum: [battery_low, speed_limit, geofence_enter, geofence_exit, sentry_mode]
diff --git a/helm/teslasync/files/grafana/dashboards/slo-alert_pack_ai_availability.json b/helm/teslasync/files/grafana/dashboards/slo-alert_pack_ai_availability.json
new file mode 100644
index 0000000000..a6562476c6
--- /dev/null
+++ b/helm/teslasync/files/grafana/dashboards/slo-alert_pack_ai_availability.json
@@ -0,0 +1,235 @@
+{
+ "uid": "slo-alert_pack_ai_availability",
+ "title": "SLO: alert_pack_ai_availability",
+ "description": "Opt-in Helix custom pack proposals must avoid server errors.",
+ "tags": [
+ "slo",
+ "owner:notifications",
+ "http",
+ "ai",
+ "alerts"
+ ],
+ "schemaVersion": 38,
+ "version": 1,
+ "time": {
+ "from": "now-24h",
+ "to": "now"
+ },
+ "refresh": "30s",
+ "panels": [
+ {
+ "id": 1,
+ "type": "stat",
+ "title": "SLI (5m)",
+ "description": "Current good/valid ratio.",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "DS_TESLASYNC_PROMETHEUS"
+ },
+ "gridPos": {
+ "x": 0,
+ "y": 0,
+ "w": 6,
+ "h": 4
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "expr": "slo:alert_pack_ai_availability:ratio_rate5m",
+ "legendFormat": "ratio_5m"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "percentunit",
+ "min": 0,
+ "max": 1,
+ "decimals": 3
+ },
+ "overrides": null
+ }
+ },
+ {
+ "id": 2,
+ "type": "stat",
+ "title": "Objective",
+ "description": "SLO target.",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "DS_TESLASYNC_PROMETHEUS"
+ },
+ "gridPos": {
+ "x": 6,
+ "y": 0,
+ "w": 6,
+ "h": 4
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "expr": "vector(0.99)",
+ "legendFormat": "objective"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "percentunit",
+ "min": 0,
+ "max": 1,
+ "decimals": 3
+ },
+ "overrides": null
+ }
+ },
+ {
+ "id": 3,
+ "type": "stat",
+ "title": "Error budget remaining (30d)",
+ "description": "1 - (1 - SLI_30d) / (1 - objective). Negative = budget blown.",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "DS_TESLASYNC_PROMETHEUS"
+ },
+ "gridPos": {
+ "x": 12,
+ "y": 0,
+ "w": 12,
+ "h": 4
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "expr": "1 - ((1 - slo:alert_pack_ai_availability:ratio_rate30d) / (1 - 0.99))",
+ "legendFormat": "budget_remaining"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "percentunit",
+ "min": 0,
+ "max": 1,
+ "decimals": 3
+ },
+ "overrides": null
+ }
+ },
+ {
+ "id": 4,
+ "type": "timeseries",
+ "title": "SLI over time (5m / 1h / 6h)",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "DS_TESLASYNC_PROMETHEUS"
+ },
+ "gridPos": {
+ "x": 0,
+ "y": 4,
+ "w": 24,
+ "h": 8
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "expr": "slo:alert_pack_ai_availability:ratio_rate5m",
+ "legendFormat": "5m"
+ },
+ {
+ "refId": "B",
+ "expr": "slo:alert_pack_ai_availability:ratio_rate1h",
+ "legendFormat": "1h"
+ },
+ {
+ "refId": "C",
+ "expr": "slo:alert_pack_ai_availability:ratio_rate6h",
+ "legendFormat": "6h"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "percentunit",
+ "min": 0,
+ "max": 1,
+ "decimals": 3
+ },
+ "overrides": null
+ }
+ },
+ {
+ "id": 5,
+ "type": "timeseries",
+ "title": "Burn rate (1h / 6h)",
+ "description": "Bad-event ratio multiplied so the y-axis is in 'budgets per hour' units.",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "DS_TESLASYNC_PROMETHEUS"
+ },
+ "gridPos": {
+ "x": 0,
+ "y": 12,
+ "w": 24,
+ "h": 8
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "expr": "(1 - slo:alert_pack_ai_availability:ratio_rate1h) / (1 - 0.99)",
+ "legendFormat": "1h"
+ },
+ {
+ "refId": "B",
+ "expr": "(1 - slo:alert_pack_ai_availability:ratio_rate6h) / (1 - 0.99)",
+ "legendFormat": "6h"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "decimals": 3
+ },
+ "overrides": null
+ }
+ },
+ {
+ "id": 6,
+ "type": "timeseries",
+ "title": "Latency (with Tempo exemplars)",
+ "description": "Histogram of the underlying SLI denominator. Exemplars link to Tempo for trace IDs that touched the latency bucket.",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "DS_TESLASYNC_PROMETHEUS"
+ },
+ "gridPos": {
+ "x": 0,
+ "y": 20,
+ "w": 24,
+ "h": 8
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "expr": "histogram_quantile(0.99, sum by (le) (rate(teslasync_red_http_request_duration_seconds_bucket{route=\"/api/v1/ai/alerts/packs/draft\"}[5m])))",
+ "legendFormat": "p99",
+ "exemplar": true
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "s"
+ },
+ "overrides": null
+ }
+ }
+ ],
+ "templating": {
+ "list": []
+ },
+ "links": [
+ {
+ "title": "Tempo (traces)",
+ "type": "link",
+ "url": "/explore?left=%7B%22datasource%22:%22tempo%22%7D"
+ }
+ ],
+ "annotations": {
+ "list": []
+ }
+}
diff --git a/helm/teslasync/files/grafana/dashboards/slo-alert_packs_availability.json b/helm/teslasync/files/grafana/dashboards/slo-alert_packs_availability.json
new file mode 100644
index 0000000000..ff1ed18bb5
--- /dev/null
+++ b/helm/teslasync/files/grafana/dashboards/slo-alert_packs_availability.json
@@ -0,0 +1,234 @@
+{
+ "uid": "slo-alert_packs_availability",
+ "title": "SLO: alert_packs_availability",
+ "description": "Alert pack catalog, installations, removal and bulk rule deletion endpoints must avoid server errors.",
+ "tags": [
+ "slo",
+ "owner:notifications",
+ "http",
+ "alerts"
+ ],
+ "schemaVersion": 38,
+ "version": 1,
+ "time": {
+ "from": "now-24h",
+ "to": "now"
+ },
+ "refresh": "30s",
+ "panels": [
+ {
+ "id": 1,
+ "type": "stat",
+ "title": "SLI (5m)",
+ "description": "Current good/valid ratio.",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "DS_TESLASYNC_PROMETHEUS"
+ },
+ "gridPos": {
+ "x": 0,
+ "y": 0,
+ "w": 6,
+ "h": 4
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "expr": "slo:alert_packs_availability:ratio_rate5m",
+ "legendFormat": "ratio_5m"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "percentunit",
+ "min": 0,
+ "max": 1,
+ "decimals": 3
+ },
+ "overrides": null
+ }
+ },
+ {
+ "id": 2,
+ "type": "stat",
+ "title": "Objective",
+ "description": "SLO target.",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "DS_TESLASYNC_PROMETHEUS"
+ },
+ "gridPos": {
+ "x": 6,
+ "y": 0,
+ "w": 6,
+ "h": 4
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "expr": "vector(0.995)",
+ "legendFormat": "objective"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "percentunit",
+ "min": 0,
+ "max": 1,
+ "decimals": 3
+ },
+ "overrides": null
+ }
+ },
+ {
+ "id": 3,
+ "type": "stat",
+ "title": "Error budget remaining (30d)",
+ "description": "1 - (1 - SLI_30d) / (1 - objective). Negative = budget blown.",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "DS_TESLASYNC_PROMETHEUS"
+ },
+ "gridPos": {
+ "x": 12,
+ "y": 0,
+ "w": 12,
+ "h": 4
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "expr": "1 - ((1 - slo:alert_packs_availability:ratio_rate30d) / (1 - 0.995))",
+ "legendFormat": "budget_remaining"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "percentunit",
+ "min": 0,
+ "max": 1,
+ "decimals": 3
+ },
+ "overrides": null
+ }
+ },
+ {
+ "id": 4,
+ "type": "timeseries",
+ "title": "SLI over time (5m / 1h / 6h)",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "DS_TESLASYNC_PROMETHEUS"
+ },
+ "gridPos": {
+ "x": 0,
+ "y": 4,
+ "w": 24,
+ "h": 8
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "expr": "slo:alert_packs_availability:ratio_rate5m",
+ "legendFormat": "5m"
+ },
+ {
+ "refId": "B",
+ "expr": "slo:alert_packs_availability:ratio_rate1h",
+ "legendFormat": "1h"
+ },
+ {
+ "refId": "C",
+ "expr": "slo:alert_packs_availability:ratio_rate6h",
+ "legendFormat": "6h"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "percentunit",
+ "min": 0,
+ "max": 1,
+ "decimals": 3
+ },
+ "overrides": null
+ }
+ },
+ {
+ "id": 5,
+ "type": "timeseries",
+ "title": "Burn rate (1h / 6h)",
+ "description": "Bad-event ratio multiplied so the y-axis is in 'budgets per hour' units.",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "DS_TESLASYNC_PROMETHEUS"
+ },
+ "gridPos": {
+ "x": 0,
+ "y": 12,
+ "w": 24,
+ "h": 8
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "expr": "(1 - slo:alert_packs_availability:ratio_rate1h) / (1 - 0.995)",
+ "legendFormat": "1h"
+ },
+ {
+ "refId": "B",
+ "expr": "(1 - slo:alert_packs_availability:ratio_rate6h) / (1 - 0.995)",
+ "legendFormat": "6h"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "decimals": 3
+ },
+ "overrides": null
+ }
+ },
+ {
+ "id": 6,
+ "type": "timeseries",
+ "title": "Latency (with Tempo exemplars)",
+ "description": "Histogram of the underlying SLI denominator. Exemplars link to Tempo for trace IDs that touched the latency bucket.",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "DS_TESLASYNC_PROMETHEUS"
+ },
+ "gridPos": {
+ "x": 0,
+ "y": 20,
+ "w": 24,
+ "h": 8
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "expr": "histogram_quantile(0.99, sum by (le) (rate(teslasync_red_http_request_duration_seconds_bucket{route=~\"/api/v1/alerts/(packs|pack-installations|rules/bulk/delete).*\"}[5m])))",
+ "legendFormat": "p99",
+ "exemplar": true
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "s"
+ },
+ "overrides": null
+ }
+ }
+ ],
+ "templating": {
+ "list": []
+ },
+ "links": [
+ {
+ "title": "Tempo (traces)",
+ "type": "link",
+ "url": "/explore?left=%7B%22datasource%22:%22tempo%22%7D"
+ }
+ ],
+ "annotations": {
+ "list": []
+ }
+}
diff --git a/helm/teslasync/files/grafana/dashboards/slo-overview.json b/helm/teslasync/files/grafana/dashboards/slo-overview.json
index 5c1a1a9140..37e4a62e21 100644
--- a/helm/teslasync/files/grafana/dashboards/slo-overview.json
+++ b/helm/teslasync/files/grafana/dashboards/slo-overview.json
@@ -17,8 +17,8 @@
{
"id": 1,
"type": "stat",
- "title": "api_availability",
- "description": "Objective: 99.5%; owner: platform",
+ "title": "alert_packs_availability",
+ "description": "Objective: 99.5%; owner: notifications",
"datasource": {
"type": "prometheus",
"uid": "DS_TESLASYNC_PROMETHEUS"
@@ -32,7 +32,7 @@
"targets": [
{
"refId": "A",
- "expr": "slo:api_availability:ratio_rate1h",
+ "expr": "slo:alert_packs_availability:ratio_rate1h",
"legendFormat": "1h"
}
],
@@ -60,8 +60,8 @@
{
"id": 2,
"type": "stat",
- "title": "api_latency_p99_500ms",
- "description": "Objective: 99%; owner: platform",
+ "title": "alert_pack_ai_availability",
+ "description": "Objective: 99%; owner: notifications",
"datasource": {
"type": "prometheus",
"uid": "DS_TESLASYNC_PROMETHEUS"
@@ -75,7 +75,7 @@
"targets": [
{
"refId": "A",
- "expr": "slo:api_latency_p99_500ms:ratio_rate1h",
+ "expr": "slo:alert_pack_ai_availability:ratio_rate1h",
"legendFormat": "1h"
}
],
@@ -103,7 +103,7 @@
{
"id": 3,
"type": "stat",
- "title": "fleet_state_batch_availability",
+ "title": "api_availability",
"description": "Objective: 99.5%; owner: platform",
"datasource": {
"type": "prometheus",
@@ -118,7 +118,7 @@
"targets": [
{
"refId": "A",
- "expr": "slo:fleet_state_batch_availability:ratio_rate1h",
+ "expr": "slo:api_availability:ratio_rate1h",
"legendFormat": "1h"
}
],
@@ -146,7 +146,7 @@
{
"id": 4,
"type": "stat",
- "title": "fleet_state_batch_latency_1s",
+ "title": "api_latency_p99_500ms",
"description": "Objective: 99%; owner: platform",
"datasource": {
"type": "prometheus",
@@ -161,7 +161,7 @@
"targets": [
{
"refId": "A",
- "expr": "slo:fleet_state_batch_latency_1s:ratio_rate1h",
+ "expr": "slo:api_latency_p99_500ms:ratio_rate1h",
"legendFormat": "1h"
}
],
@@ -189,8 +189,8 @@
{
"id": 5,
"type": "stat",
- "title": "signal_transport_agreement_availability",
- "description": "Objective: 99%; owner: platform",
+ "title": "fleet_state_batch_availability",
+ "description": "Objective: 99.5%; owner: platform",
"datasource": {
"type": "prometheus",
"uid": "DS_TESLASYNC_PROMETHEUS"
@@ -204,7 +204,7 @@
"targets": [
{
"refId": "A",
- "expr": "slo:signal_transport_agreement_availability:ratio_rate1h",
+ "expr": "slo:fleet_state_batch_availability:ratio_rate1h",
"legendFormat": "1h"
}
],
@@ -232,7 +232,7 @@
{
"id": 6,
"type": "stat",
- "title": "signal_transport_agreement_latency_2s",
+ "title": "fleet_state_batch_latency_1s",
"description": "Objective: 99%; owner: platform",
"datasource": {
"type": "prometheus",
@@ -247,7 +247,7 @@
"targets": [
{
"refId": "A",
- "expr": "slo:signal_transport_agreement_latency_2s:ratio_rate1h",
+ "expr": "slo:fleet_state_batch_latency_1s:ratio_rate1h",
"legendFormat": "1h"
}
],
@@ -275,7 +275,7 @@
{
"id": 7,
"type": "stat",
- "title": "data_quality_read_availability",
+ "title": "signal_transport_agreement_availability",
"description": "Objective: 99%; owner: platform",
"datasource": {
"type": "prometheus",
@@ -290,7 +290,7 @@
"targets": [
{
"refId": "A",
- "expr": "slo:data_quality_read_availability:ratio_rate1h",
+ "expr": "slo:signal_transport_agreement_availability:ratio_rate1h",
"legendFormat": "1h"
}
],
@@ -318,7 +318,7 @@
{
"id": 8,
"type": "stat",
- "title": "data_quality_read_latency_2s",
+ "title": "signal_transport_agreement_latency_2s",
"description": "Objective: 99%; owner: platform",
"datasource": {
"type": "prometheus",
@@ -333,7 +333,7 @@
"targets": [
{
"refId": "A",
- "expr": "slo:data_quality_read_latency_2s:ratio_rate1h",
+ "expr": "slo:signal_transport_agreement_latency_2s:ratio_rate1h",
"legendFormat": "1h"
}
],
@@ -361,8 +361,8 @@
{
"id": 9,
"type": "stat",
- "title": "battery_health_latency_1s",
- "description": "Objective: 99%; owner: analytics",
+ "title": "data_quality_read_availability",
+ "description": "Objective: 99%; owner: platform",
"datasource": {
"type": "prometheus",
"uid": "DS_TESLASYNC_PROMETHEUS"
@@ -376,7 +376,7 @@
"targets": [
{
"refId": "A",
- "expr": "slo:battery_health_latency_1s:ratio_rate1h",
+ "expr": "slo:data_quality_read_availability:ratio_rate1h",
"legendFormat": "1h"
}
],
@@ -404,7 +404,7 @@
{
"id": 10,
"type": "stat",
- "title": "vehicle_management_availability",
+ "title": "data_quality_read_latency_2s",
"description": "Objective: 99%; owner: platform",
"datasource": {
"type": "prometheus",
@@ -419,7 +419,7 @@
"targets": [
{
"refId": "A",
- "expr": "slo:vehicle_management_availability:ratio_rate1h",
+ "expr": "slo:data_quality_read_latency_2s:ratio_rate1h",
"legendFormat": "1h"
}
],
@@ -447,8 +447,8 @@
{
"id": 11,
"type": "stat",
- "title": "service_intelligence_availability",
- "description": "Objective: 99%; owner: platform",
+ "title": "battery_health_latency_1s",
+ "description": "Objective: 99%; owner: analytics",
"datasource": {
"type": "prometheus",
"uid": "DS_TESLASYNC_PROMETHEUS"
@@ -462,7 +462,7 @@
"targets": [
{
"refId": "A",
- "expr": "slo:service_intelligence_availability:ratio_rate1h",
+ "expr": "slo:battery_health_latency_1s:ratio_rate1h",
"legendFormat": "1h"
}
],
@@ -490,7 +490,7 @@
{
"id": 12,
"type": "stat",
- "title": "service_intelligence_catalog_admin_availability",
+ "title": "vehicle_management_availability",
"description": "Objective: 99%; owner: platform",
"datasource": {
"type": "prometheus",
@@ -505,7 +505,7 @@
"targets": [
{
"refId": "A",
- "expr": "slo:service_intelligence_catalog_admin_availability:ratio_rate1h",
+ "expr": "slo:vehicle_management_availability:ratio_rate1h",
"legendFormat": "1h"
}
],
@@ -533,7 +533,7 @@
{
"id": 13,
"type": "stat",
- "title": "action_center_read_availability",
+ "title": "service_intelligence_availability",
"description": "Objective: 99%; owner: platform",
"datasource": {
"type": "prometheus",
@@ -548,7 +548,7 @@
"targets": [
{
"refId": "A",
- "expr": "slo:action_center_read_availability:ratio_rate1h",
+ "expr": "slo:service_intelligence_availability:ratio_rate1h",
"legendFormat": "1h"
}
],
@@ -576,7 +576,7 @@
{
"id": 14,
"type": "stat",
- "title": "activity_timeline_read_availability",
+ "title": "service_intelligence_catalog_admin_availability",
"description": "Objective: 99%; owner: platform",
"datasource": {
"type": "prometheus",
@@ -591,7 +591,7 @@
"targets": [
{
"refId": "A",
- "expr": "slo:activity_timeline_read_availability:ratio_rate1h",
+ "expr": "slo:service_intelligence_catalog_admin_availability:ratio_rate1h",
"legendFormat": "1h"
}
],
@@ -619,8 +619,8 @@
{
"id": 15,
"type": "stat",
- "title": "data_repair_read_availability",
- "description": "Objective: 99.5%; owner: platform",
+ "title": "action_center_read_availability",
+ "description": "Objective: 99%; owner: platform",
"datasource": {
"type": "prometheus",
"uid": "DS_TESLASYNC_PROMETHEUS"
@@ -634,7 +634,7 @@
"targets": [
{
"refId": "A",
- "expr": "slo:data_repair_read_availability:ratio_rate1h",
+ "expr": "slo:action_center_read_availability:ratio_rate1h",
"legendFormat": "1h"
}
],
@@ -662,8 +662,8 @@
{
"id": 16,
"type": "stat",
- "title": "data_repair_write_availability",
- "description": "Objective: 99.5%; owner: platform",
+ "title": "activity_timeline_read_availability",
+ "description": "Objective: 99%; owner: platform",
"datasource": {
"type": "prometheus",
"uid": "DS_TESLASYNC_PROMETHEUS"
@@ -677,7 +677,7 @@
"targets": [
{
"refId": "A",
- "expr": "slo:data_repair_write_availability:ratio_rate1h",
+ "expr": "slo:activity_timeline_read_availability:ratio_rate1h",
"legendFormat": "1h"
}
],
@@ -705,7 +705,7 @@
{
"id": 17,
"type": "stat",
- "title": "action_center_write_availability",
+ "title": "data_repair_read_availability",
"description": "Objective: 99.5%; owner: platform",
"datasource": {
"type": "prometheus",
@@ -720,7 +720,7 @@
"targets": [
{
"refId": "A",
- "expr": "slo:action_center_write_availability:ratio_rate1h",
+ "expr": "slo:data_repair_read_availability:ratio_rate1h",
"legendFormat": "1h"
}
],
@@ -748,6 +748,92 @@
{
"id": 18,
"type": "stat",
+ "title": "data_repair_write_availability",
+ "description": "Objective: 99.5%; owner: platform",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "DS_TESLASYNC_PROMETHEUS"
+ },
+ "gridPos": {
+ "x": 6,
+ "y": 16,
+ "w": 6,
+ "h": 4
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "expr": "slo:data_repair_write_availability:ratio_rate1h",
+ "legendFormat": "1h"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "percentunit",
+ "min": 0,
+ "max": 1,
+ "decimals": 3
+ },
+ "overrides": null
+ },
+ "options": {
+ "colorMode": "value",
+ "orientation": "horizontal",
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ],
+ "fields": "",
+ "values": false
+ }
+ }
+ },
+ {
+ "id": 19,
+ "type": "stat",
+ "title": "action_center_write_availability",
+ "description": "Objective: 99.5%; owner: platform",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "DS_TESLASYNC_PROMETHEUS"
+ },
+ "gridPos": {
+ "x": 12,
+ "y": 16,
+ "w": 6,
+ "h": 4
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "expr": "slo:action_center_write_availability:ratio_rate1h",
+ "legendFormat": "1h"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "percentunit",
+ "min": 0,
+ "max": 1,
+ "decimals": 3
+ },
+ "overrides": null
+ },
+ "options": {
+ "colorMode": "value",
+ "orientation": "horizontal",
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ],
+ "fields": "",
+ "values": false
+ }
+ }
+ },
+ {
+ "id": 20,
+ "type": "stat",
"title": "advanced_intelligence_read_availability",
"description": "Objective: 99%; owner: analytics",
"datasource": {
@@ -755,7 +841,7 @@
"uid": "DS_TESLASYNC_PROMETHEUS"
},
"gridPos": {
- "x": 6,
+ "x": 18,
"y": 16,
"w": 6,
"h": 4
@@ -789,7 +875,7 @@
}
},
{
- "id": 19,
+ "id": 21,
"type": "stat",
"title": "advanced_intelligence_scenario_availability",
"description": "Objective: 99%; owner: analytics",
@@ -798,8 +884,8 @@
"uid": "DS_TESLASYNC_PROMETHEUS"
},
"gridPos": {
- "x": 12,
- "y": 16,
+ "x": 0,
+ "y": 20,
"w": 6,
"h": 4
},
@@ -832,7 +918,7 @@
}
},
{
- "id": 20,
+ "id": 22,
"type": "stat",
"title": "telemetry_freshness",
"description": "Objective: 99%; owner: ingest",
@@ -841,8 +927,8 @@
"uid": "DS_TESLASYNC_PROMETHEUS"
},
"gridPos": {
- "x": 18,
- "y": 16,
+ "x": 6,
+ "y": 20,
"w": 6,
"h": 4
},
@@ -875,7 +961,7 @@
}
},
{
- "id": 21,
+ "id": 23,
"type": "stat",
"title": "normalize_throughput",
"description": "Objective: 95%; owner: ingest",
@@ -884,7 +970,7 @@
"uid": "DS_TESLASYNC_PROMETHEUS"
},
"gridPos": {
- "x": 0,
+ "x": 12,
"y": 20,
"w": 6,
"h": 4
@@ -918,7 +1004,7 @@
}
},
{
- "id": 22,
+ "id": 24,
"type": "stat",
"title": "mqtt_pipeline_subscription",
"description": "Objective: 99.9%; owner: ingest",
@@ -927,7 +1013,7 @@
"uid": "DS_TESLASYNC_PROMETHEUS"
},
"gridPos": {
- "x": 6,
+ "x": 18,
"y": 20,
"w": 6,
"h": 4
@@ -961,7 +1047,7 @@
}
},
{
- "id": 23,
+ "id": 25,
"type": "stat",
"title": "mqtt_handler_backlog",
"description": "Objective: 99%; owner: ingest",
@@ -970,8 +1056,8 @@
"uid": "DS_TESLASYNC_PROMETHEUS"
},
"gridPos": {
- "x": 12,
- "y": 20,
+ "x": 0,
+ "y": 24,
"w": 6,
"h": 4
},
@@ -1004,7 +1090,7 @@
}
},
{
- "id": 24,
+ "id": 26,
"type": "stat",
"title": "tesla_api_availability",
"description": "Objective: 99%; owner: platform",
@@ -1013,8 +1099,8 @@
"uid": "DS_TESLASYNC_PROMETHEUS"
},
"gridPos": {
- "x": 18,
- "y": 20,
+ "x": 6,
+ "y": 24,
"w": 6,
"h": 4
},
@@ -1047,7 +1133,7 @@
}
},
{
- "id": 25,
+ "id": 27,
"type": "stat",
"title": "fleet_api_budget_evidence_availability",
"description": "Objective: 99%; owner: platform",
@@ -1056,7 +1142,7 @@
"uid": "DS_TESLASYNC_PROMETHEUS"
},
"gridPos": {
- "x": 0,
+ "x": 12,
"y": 24,
"w": 6,
"h": 4
@@ -1090,7 +1176,7 @@
}
},
{
- "id": 26,
+ "id": 28,
"type": "stat",
"title": "fleet_api_polling_budget_continuity",
"description": "Objective: 99%; owner: platform",
@@ -1099,7 +1185,7 @@
"uid": "DS_TESLASYNC_PROMETHEUS"
},
"gridPos": {
- "x": 6,
+ "x": 18,
"y": 24,
"w": 6,
"h": 4
@@ -1133,7 +1219,7 @@
}
},
{
- "id": 27,
+ "id": 29,
"type": "stat",
"title": "sse_delivery",
"description": "Objective: 95%; owner: realtime",
@@ -1142,8 +1228,8 @@
"uid": "DS_TESLASYNC_PROMETHEUS"
},
"gridPos": {
- "x": 12,
- "y": 24,
+ "x": 0,
+ "y": 28,
"w": 6,
"h": 4
},
@@ -1176,7 +1262,7 @@
}
},
{
- "id": 28,
+ "id": 30,
"type": "stat",
"title": "frontend_lcp",
"description": "Objective: 90%; owner: frontend",
@@ -1185,8 +1271,8 @@
"uid": "DS_TESLASYNC_PROMETHEUS"
},
"gridPos": {
- "x": 18,
- "y": 24,
+ "x": 6,
+ "y": 28,
"w": 6,
"h": 4
},
@@ -1219,7 +1305,7 @@
}
},
{
- "id": 29,
+ "id": 31,
"type": "stat",
"title": "frontend_inp",
"description": "Objective: 90%; owner: frontend",
@@ -1228,7 +1314,7 @@
"uid": "DS_TESLASYNC_PROMETHEUS"
},
"gridPos": {
- "x": 0,
+ "x": 12,
"y": 28,
"w": 6,
"h": 4
@@ -1262,7 +1348,7 @@
}
},
{
- "id": 30,
+ "id": 32,
"type": "stat",
"title": "frontend_cls",
"description": "Objective: 90%; owner: frontend",
@@ -1271,7 +1357,7 @@
"uid": "DS_TESLASYNC_PROMETHEUS"
},
"gridPos": {
- "x": 6,
+ "x": 18,
"y": 28,
"w": 6,
"h": 4
@@ -1305,7 +1391,7 @@
}
},
{
- "id": 31,
+ "id": 33,
"type": "stat",
"title": "frontend_fcp",
"description": "Objective: 90%; owner: frontend",
@@ -1314,8 +1400,8 @@
"uid": "DS_TESLASYNC_PROMETHEUS"
},
"gridPos": {
- "x": 12,
- "y": 28,
+ "x": 0,
+ "y": 32,
"w": 6,
"h": 4
},
@@ -1348,7 +1434,7 @@
}
},
{
- "id": 32,
+ "id": 34,
"type": "stat",
"title": "frontend_ttfb",
"description": "Objective: 90%; owner: frontend",
@@ -1357,8 +1443,8 @@
"uid": "DS_TESLASYNC_PROMETHEUS"
},
"gridPos": {
- "x": 18,
- "y": 28,
+ "x": 6,
+ "y": 32,
"w": 6,
"h": 4
},
@@ -1391,7 +1477,7 @@
}
},
{
- "id": 33,
+ "id": 35,
"type": "stat",
"title": "frontend_route_transition",
"description": "Objective: 90%; owner: frontend",
@@ -1400,7 +1486,7 @@
"uid": "DS_TESLASYNC_PROMETHEUS"
},
"gridPos": {
- "x": 0,
+ "x": 12,
"y": 32,
"w": 6,
"h": 4
@@ -1434,7 +1520,7 @@
}
},
{
- "id": 34,
+ "id": 36,
"type": "stat",
"title": "frontend_rum_ingest_availability",
"description": "Objective: 99.5%; owner: frontend",
@@ -1443,7 +1529,7 @@
"uid": "DS_TESLASYNC_PROMETHEUS"
},
"gridPos": {
- "x": 6,
+ "x": 18,
"y": 32,
"w": 6,
"h": 4
@@ -1477,7 +1563,7 @@
}
},
{
- "id": 35,
+ "id": 37,
"type": "stat",
"title": "fleet_ops_availability",
"description": "Objective: 99.5%; owner: fleet",
@@ -1486,8 +1572,8 @@
"uid": "DS_TESLASYNC_PROMETHEUS"
},
"gridPos": {
- "x": 12,
- "y": 32,
+ "x": 0,
+ "y": 36,
"w": 6,
"h": 4
},
@@ -1520,7 +1606,7 @@
}
},
{
- "id": 36,
+ "id": 38,
"type": "stat",
"title": "fleet_ops_forecast_latency",
"description": "Objective: 99%; owner: fleet",
@@ -1529,8 +1615,8 @@
"uid": "DS_TESLASYNC_PROMETHEUS"
},
"gridPos": {
- "x": 18,
- "y": 32,
+ "x": 6,
+ "y": 36,
"w": 6,
"h": 4
},
@@ -1563,7 +1649,7 @@
}
},
{
- "id": 37,
+ "id": 39,
"type": "stat",
"title": "benchmark_privacy_status_availability",
"description": "Objective: 99.5%; owner: analytics",
@@ -1572,7 +1658,7 @@
"uid": "DS_TESLASYNC_PROMETHEUS"
},
"gridPos": {
- "x": 0,
+ "x": 12,
"y": 36,
"w": 6,
"h": 4
@@ -1606,7 +1692,7 @@
}
},
{
- "id": 38,
+ "id": 40,
"type": "stat",
"title": "benchmark_consent_availability",
"description": "Objective: 99%; owner: analytics",
@@ -1615,7 +1701,7 @@
"uid": "DS_TESLASYNC_PROMETHEUS"
},
"gridPos": {
- "x": 6,
+ "x": 18,
"y": 36,
"w": 6,
"h": 4
@@ -1649,7 +1735,7 @@
}
},
{
- "id": 39,
+ "id": 41,
"type": "stat",
"title": "benchmark_release_read_availability",
"description": "Objective: 99.5%; owner: analytics",
@@ -1658,8 +1744,8 @@
"uid": "DS_TESLASYNC_PROMETHEUS"
},
"gridPos": {
- "x": 12,
- "y": 36,
+ "x": 0,
+ "y": 40,
"w": 6,
"h": 4
},
@@ -1692,7 +1778,7 @@
}
},
{
- "id": 40,
+ "id": 42,
"type": "stat",
"title": "benchmark_release_creation_availability",
"description": "Objective: 99%; owner: analytics",
@@ -1701,8 +1787,8 @@
"uid": "DS_TESLASYNC_PROMETHEUS"
},
"gridPos": {
- "x": 18,
- "y": 36,
+ "x": 6,
+ "y": 40,
"w": 6,
"h": 4
},
@@ -1735,7 +1821,7 @@
}
},
{
- "id": 41,
+ "id": 43,
"type": "stat",
"title": "geofence_pricing_read_availability",
"description": "Objective: 99.5%; owner: platform",
@@ -1744,7 +1830,7 @@
"uid": "DS_TESLASYNC_PROMETHEUS"
},
"gridPos": {
- "x": 0,
+ "x": 12,
"y": 40,
"w": 6,
"h": 4
@@ -1778,7 +1864,7 @@
}
},
{
- "id": 42,
+ "id": 44,
"type": "stat",
"title": "geofence_pricing_write_availability",
"description": "Objective: 99%; owner: platform",
@@ -1787,7 +1873,7 @@
"uid": "DS_TESLASYNC_PROMETHEUS"
},
"gridPos": {
- "x": 6,
+ "x": 18,
"y": 40,
"w": 6,
"h": 4
@@ -1821,7 +1907,7 @@
}
},
{
- "id": 43,
+ "id": 45,
"type": "stat",
"title": "fsd_insights_availability",
"description": "Objective: 99.5%; owner: analytics",
@@ -1830,8 +1916,8 @@
"uid": "DS_TESLASYNC_PROMETHEUS"
},
"gridPos": {
- "x": 12,
- "y": 40,
+ "x": 0,
+ "y": 44,
"w": 6,
"h": 4
},
@@ -1864,7 +1950,7 @@
}
},
{
- "id": 44,
+ "id": 46,
"type": "stat",
"title": "fsd_insights_latency_1s",
"description": "Objective: 99%; owner: analytics",
@@ -1873,8 +1959,8 @@
"uid": "DS_TESLASYNC_PROMETHEUS"
},
"gridPos": {
- "x": 18,
- "y": 40,
+ "x": 6,
+ "y": 44,
"w": 6,
"h": 4
},
@@ -1907,7 +1993,7 @@
}
},
{
- "id": 45,
+ "id": 47,
"type": "stat",
"title": "physics_ledger_availability",
"description": "Objective: 99.5%; owner: physics",
@@ -1916,7 +2002,7 @@
"uid": "DS_TESLASYNC_PROMETHEUS"
},
"gridPos": {
- "x": 0,
+ "x": 12,
"y": 44,
"w": 6,
"h": 4
@@ -1950,7 +2036,7 @@
}
},
{
- "id": 46,
+ "id": 48,
"type": "stat",
"title": "science_lab_availability",
"description": "Objective: 99.5%; owner: science",
@@ -1959,7 +2045,7 @@
"uid": "DS_TESLASYNC_PROMETHEUS"
},
"gridPos": {
- "x": 6,
+ "x": 18,
"y": 44,
"w": 6,
"h": 4
diff --git a/helm/teslasync/files/prometheus/alerting-rules.yaml b/helm/teslasync/files/prometheus/alerting-rules.yaml
index e01a5aca39..f15bb0e279 100644
--- a/helm/teslasync/files/prometheus/alerting-rules.yaml
+++ b/helm/teslasync/files/prometheus/alerting-rules.yaml
@@ -2,6 +2,66 @@
# Source: slo/catalog.yaml
# Multi-window multi-burn-rate per Google SRE Workbook ch. 5.
groups:
+ - name: slo_alert_packs_availability_burn
+ rules:
+ - alert: SLOAlertPacksAvailabilityFastBurn
+ expr: "(1 - slo:alert_packs_availability:ratio_rate1h) > 0.072000 and (1 - slo:alert_packs_availability:ratio_rate5m) > 0.072000"
+ for: 2m
+ labels:
+ severity: page
+ slo: "alert_packs_availability"
+ owner: "notifications"
+ burn_rate: "14.4"
+ long_window: "1h"
+ short_window: "5m"
+ annotations:
+ summary: "SLO alert_packs_availability fast burn: error budget consumed at >=14.4x sustainable rate over the last 1h"
+ description: "Error budget for SLO \"alert_packs_availability\" (objective 99.5%, owner notifications) is burning at >=14.4x the sustainable rate on both the 1h and 5m windows."
+ runbook_url: "docs/runbooks/phase-44-respond-to-burn-alert.md"
+ - alert: SLOAlertPacksAvailabilitySlowBurn
+ expr: "(1 - slo:alert_packs_availability:ratio_rate6h) > 0.030000 and (1 - (((((sum(rate(teslasync_red_http_requests_total{route=~\"/api/v1/alerts/(packs|pack-installations|rules/bulk/delete).*\",status_class!=\"5xx\"}[30m]))) or on() (0 * (sum(rate(teslasync_red_http_requests_total{route=~\"/api/v1/alerts/(packs|pack-installations|rules/bulk/delete).*\"}[30m])))))) / (sum(rate(teslasync_red_http_requests_total{route=~\"/api/v1/alerts/(packs|pack-installations|rules/bulk/delete).*\"}[30m])))) and on() ((sum(rate(teslasync_red_http_requests_total{route=~\"/api/v1/alerts/(packs|pack-installations|rules/bulk/delete).*\"}[30m]))) > 0)) or on() vector(1)) > 0.030000"
+ for: 15m
+ labels:
+ severity: ticket
+ slo: "alert_packs_availability"
+ owner: "notifications"
+ burn_rate: "6"
+ long_window: "6h"
+ short_window: "30m"
+ annotations:
+ summary: "SLO alert_packs_availability slow burn: error budget consumed at >=6x sustainable rate over the last 6h"
+ description: "Error budget for SLO \"alert_packs_availability\" (objective 99.5%, owner notifications) is burning at >=6x the sustainable rate on both the 6h and 30m windows."
+ runbook_url: "docs/runbooks/phase-44-respond-to-burn-alert.md"
+ - name: slo_alert_pack_ai_availability_burn
+ rules:
+ - alert: SLOAlertPackAiAvailabilityFastBurn
+ expr: "(1 - slo:alert_pack_ai_availability:ratio_rate1h) > 0.144000 and (1 - slo:alert_pack_ai_availability:ratio_rate5m) > 0.144000"
+ for: 2m
+ labels:
+ severity: page
+ slo: "alert_pack_ai_availability"
+ owner: "notifications"
+ burn_rate: "14.4"
+ long_window: "1h"
+ short_window: "5m"
+ annotations:
+ summary: "SLO alert_pack_ai_availability fast burn: error budget consumed at >=14.4x sustainable rate over the last 1h"
+ description: "Error budget for SLO \"alert_pack_ai_availability\" (objective 99%, owner notifications) is burning at >=14.4x the sustainable rate on both the 1h and 5m windows."
+ runbook_url: "docs/runbooks/phase-44-respond-to-burn-alert.md"
+ - alert: SLOAlertPackAiAvailabilitySlowBurn
+ expr: "(1 - slo:alert_pack_ai_availability:ratio_rate6h) > 0.060000 and (1 - (((((sum(rate(teslasync_red_http_requests_total{route=\"/api/v1/ai/alerts/packs/draft\",status_class!=\"5xx\"}[30m]))) or on() (0 * (sum(rate(teslasync_red_http_requests_total{route=\"/api/v1/ai/alerts/packs/draft\"}[30m])))))) / (sum(rate(teslasync_red_http_requests_total{route=\"/api/v1/ai/alerts/packs/draft\"}[30m])))) and on() ((sum(rate(teslasync_red_http_requests_total{route=\"/api/v1/ai/alerts/packs/draft\"}[30m]))) > 0)) or on() vector(1)) > 0.060000"
+ for: 15m
+ labels:
+ severity: ticket
+ slo: "alert_pack_ai_availability"
+ owner: "notifications"
+ burn_rate: "6"
+ long_window: "6h"
+ short_window: "30m"
+ annotations:
+ summary: "SLO alert_pack_ai_availability slow burn: error budget consumed at >=6x sustainable rate over the last 6h"
+ description: "Error budget for SLO \"alert_pack_ai_availability\" (objective 99%, owner notifications) is burning at >=6x the sustainable rate on both the 6h and 30m windows."
+ runbook_url: "docs/runbooks/phase-44-respond-to-burn-alert.md"
- name: slo_api_availability_burn
rules:
- alert: SLOApiAvailabilityFastBurn
diff --git a/helm/teslasync/files/prometheus/recording-rules.yaml b/helm/teslasync/files/prometheus/recording-rules.yaml
index 04c1a16bc0..7133e48791 100644
--- a/helm/teslasync/files/prometheus/recording-rules.yaml
+++ b/helm/teslasync/files/prometheus/recording-rules.yaml
@@ -1,6 +1,68 @@
# DO NOT EDIT. Regenerated by `go run ./cmd/slogen generate recording`.
# Source: slo/catalog.yaml
groups:
+ - name: slo_alert_packs_availability
+ interval: 30s
+ rules:
+ - record: slo:alert_packs_availability:ratio_rate5m
+ expr: "(((((sum(rate(teslasync_red_http_requests_total{route=~\"/api/v1/alerts/(packs|pack-installations|rules/bulk/delete).*\",status_class!=\"5xx\"}[5m]))) or on() (0 * (sum(rate(teslasync_red_http_requests_total{route=~\"/api/v1/alerts/(packs|pack-installations|rules/bulk/delete).*\"}[5m])))))) / (sum(rate(teslasync_red_http_requests_total{route=~\"/api/v1/alerts/(packs|pack-installations|rules/bulk/delete).*\"}[5m])))) and on() ((sum(rate(teslasync_red_http_requests_total{route=~\"/api/v1/alerts/(packs|pack-installations|rules/bulk/delete).*\"}[5m]))) > 0)) or on() vector(1)"
+ labels:
+ slo: "alert_packs_availability"
+ window: "5m"
+ owner: "notifications"
+ objective: "99.5"
+ - record: slo:alert_packs_availability:ratio_rate1h
+ expr: "(((((sum(rate(teslasync_red_http_requests_total{route=~\"/api/v1/alerts/(packs|pack-installations|rules/bulk/delete).*\",status_class!=\"5xx\"}[1h]))) or on() (0 * (sum(rate(teslasync_red_http_requests_total{route=~\"/api/v1/alerts/(packs|pack-installations|rules/bulk/delete).*\"}[1h])))))) / (sum(rate(teslasync_red_http_requests_total{route=~\"/api/v1/alerts/(packs|pack-installations|rules/bulk/delete).*\"}[1h])))) and on() ((sum(rate(teslasync_red_http_requests_total{route=~\"/api/v1/alerts/(packs|pack-installations|rules/bulk/delete).*\"}[1h]))) > 0)) or on() vector(1)"
+ labels:
+ slo: "alert_packs_availability"
+ window: "1h"
+ owner: "notifications"
+ objective: "99.5"
+ - record: slo:alert_packs_availability:ratio_rate6h
+ expr: "(((((sum(rate(teslasync_red_http_requests_total{route=~\"/api/v1/alerts/(packs|pack-installations|rules/bulk/delete).*\",status_class!=\"5xx\"}[6h]))) or on() (0 * (sum(rate(teslasync_red_http_requests_total{route=~\"/api/v1/alerts/(packs|pack-installations|rules/bulk/delete).*\"}[6h])))))) / (sum(rate(teslasync_red_http_requests_total{route=~\"/api/v1/alerts/(packs|pack-installations|rules/bulk/delete).*\"}[6h])))) and on() ((sum(rate(teslasync_red_http_requests_total{route=~\"/api/v1/alerts/(packs|pack-installations|rules/bulk/delete).*\"}[6h]))) > 0)) or on() vector(1)"
+ labels:
+ slo: "alert_packs_availability"
+ window: "6h"
+ owner: "notifications"
+ objective: "99.5"
+ - record: slo:alert_packs_availability:ratio_rate30d
+ expr: "(((((sum(rate(teslasync_red_http_requests_total{route=~\"/api/v1/alerts/(packs|pack-installations|rules/bulk/delete).*\",status_class!=\"5xx\"}[30d]))) or on() (0 * (sum(rate(teslasync_red_http_requests_total{route=~\"/api/v1/alerts/(packs|pack-installations|rules/bulk/delete).*\"}[30d])))))) / (sum(rate(teslasync_red_http_requests_total{route=~\"/api/v1/alerts/(packs|pack-installations|rules/bulk/delete).*\"}[30d])))) and on() ((sum(rate(teslasync_red_http_requests_total{route=~\"/api/v1/alerts/(packs|pack-installations|rules/bulk/delete).*\"}[30d]))) > 0)) or on() vector(1)"
+ labels:
+ slo: "alert_packs_availability"
+ window: "30d"
+ owner: "notifications"
+ objective: "99.5"
+ - name: slo_alert_pack_ai_availability
+ interval: 30s
+ rules:
+ - record: slo:alert_pack_ai_availability:ratio_rate5m
+ expr: "(((((sum(rate(teslasync_red_http_requests_total{route=\"/api/v1/ai/alerts/packs/draft\",status_class!=\"5xx\"}[5m]))) or on() (0 * (sum(rate(teslasync_red_http_requests_total{route=\"/api/v1/ai/alerts/packs/draft\"}[5m])))))) / (sum(rate(teslasync_red_http_requests_total{route=\"/api/v1/ai/alerts/packs/draft\"}[5m])))) and on() ((sum(rate(teslasync_red_http_requests_total{route=\"/api/v1/ai/alerts/packs/draft\"}[5m]))) > 0)) or on() vector(1)"
+ labels:
+ slo: "alert_pack_ai_availability"
+ window: "5m"
+ owner: "notifications"
+ objective: "99"
+ - record: slo:alert_pack_ai_availability:ratio_rate1h
+ expr: "(((((sum(rate(teslasync_red_http_requests_total{route=\"/api/v1/ai/alerts/packs/draft\",status_class!=\"5xx\"}[1h]))) or on() (0 * (sum(rate(teslasync_red_http_requests_total{route=\"/api/v1/ai/alerts/packs/draft\"}[1h])))))) / (sum(rate(teslasync_red_http_requests_total{route=\"/api/v1/ai/alerts/packs/draft\"}[1h])))) and on() ((sum(rate(teslasync_red_http_requests_total{route=\"/api/v1/ai/alerts/packs/draft\"}[1h]))) > 0)) or on() vector(1)"
+ labels:
+ slo: "alert_pack_ai_availability"
+ window: "1h"
+ owner: "notifications"
+ objective: "99"
+ - record: slo:alert_pack_ai_availability:ratio_rate6h
+ expr: "(((((sum(rate(teslasync_red_http_requests_total{route=\"/api/v1/ai/alerts/packs/draft\",status_class!=\"5xx\"}[6h]))) or on() (0 * (sum(rate(teslasync_red_http_requests_total{route=\"/api/v1/ai/alerts/packs/draft\"}[6h])))))) / (sum(rate(teslasync_red_http_requests_total{route=\"/api/v1/ai/alerts/packs/draft\"}[6h])))) and on() ((sum(rate(teslasync_red_http_requests_total{route=\"/api/v1/ai/alerts/packs/draft\"}[6h]))) > 0)) or on() vector(1)"
+ labels:
+ slo: "alert_pack_ai_availability"
+ window: "6h"
+ owner: "notifications"
+ objective: "99"
+ - record: slo:alert_pack_ai_availability:ratio_rate30d
+ expr: "(((((sum(rate(teslasync_red_http_requests_total{route=\"/api/v1/ai/alerts/packs/draft\",status_class!=\"5xx\"}[30d]))) or on() (0 * (sum(rate(teslasync_red_http_requests_total{route=\"/api/v1/ai/alerts/packs/draft\"}[30d])))))) / (sum(rate(teslasync_red_http_requests_total{route=\"/api/v1/ai/alerts/packs/draft\"}[30d])))) and on() ((sum(rate(teslasync_red_http_requests_total{route=\"/api/v1/ai/alerts/packs/draft\"}[30d]))) > 0)) or on() vector(1)"
+ labels:
+ slo: "alert_pack_ai_availability"
+ window: "30d"
+ owner: "notifications"
+ objective: "99"
- name: slo_api_availability
interval: 30s
rules:
diff --git a/internal/ai/dispatch/azure_cancellation_test.go b/internal/ai/dispatch/azure_cancellation_test.go
new file mode 100644
index 0000000000..e51e786be0
--- /dev/null
+++ b/internal/ai/dispatch/azure_cancellation_test.go
@@ -0,0 +1,72 @@
+package dispatch
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/ev-dev-labs/teslasync/internal/ai/provider"
+ "github.com/ev-dev-labs/teslasync/internal/ai/provider/azure"
+ "github.com/ev-dev-labs/teslasync/internal/ai/tools"
+)
+
+type rejectingAzureWriter struct{ *CaptureWriter }
+
+func (rejectingAzureWriter) WriteDelta(string) error { return errors.New("client disconnected") }
+
+func TestAzureDispatchClosesAbandonedUpstreamStream(t *testing.T) {
+ for _, cancelRequest := range []bool{false, true} {
+ t.Run(map[bool]string{false: "writer_failure", true: "request_cancelled"}[cancelRequest], func(t *testing.T) {
+ started, closed := make(chan struct{}), make(chan struct{})
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.WriteHeader(http.StatusOK)
+ if !cancelRequest {
+ _, _ = io.WriteString(w, "data:{\"choices\":[{\"delta\":{\"content\":\"partial\"}}]}\n\n")
+ }
+ w.(http.Flusher).Flush()
+ close(started)
+ <-r.Context().Done()
+ close(closed)
+ }))
+ defer srv.Close()
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ a, err := azure.New(provider.ProviderConfig{BaseURL: srv.URL, APIKey: "k", Model: "any"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ d := New(tools.NewRegistry(), a, nil, 3)
+ result := make(chan error, 1)
+ go func() {
+ _, _, err := d.completeTurn(ctx, provider.ChatRequest{}, rejectingAzureWriter{NewCaptureWriter()})
+ result <- err
+ }()
+ select {
+ case <-started:
+ case <-time.After(3 * time.Second):
+ t.Fatal("request did not start")
+ }
+ if cancelRequest {
+ cancel()
+ }
+ select {
+ case err := <-result:
+ if err == nil || (cancelRequest && !errors.Is(err, context.Canceled)) {
+ t.Fatalf("expected cancellation/client error: %v", err)
+ }
+ case <-time.After(3 * time.Second):
+ t.Fatal("dispatcher did not stop")
+ }
+ select {
+ case <-closed:
+ case <-time.After(3 * time.Second):
+ t.Fatal("abandoned upstream stream remains open")
+ }
+ })
+ }
+}
diff --git a/internal/ai/dispatch/azure_failure_test.go b/internal/ai/dispatch/azure_failure_test.go
new file mode 100644
index 0000000000..096cd9422d
--- /dev/null
+++ b/internal/ai/dispatch/azure_failure_test.go
@@ -0,0 +1,54 @@
+package dispatch
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+
+ "github.com/ev-dev-labs/teslasync/internal/ai/provider"
+ "github.com/ev-dev-labs/teslasync/internal/ai/provider/azure"
+ "github.com/ev-dev-labs/teslasync/internal/ai/strategy"
+ "github.com/ev-dev-labs/teslasync/internal/ai/tools"
+)
+
+func TestAzureDispatchDoesNotReplayFinalProtocolFailures(t *testing.T) {
+ for _, protocol := range []string{provider.FoundryProtocolAuto, provider.FoundryProtocolChat, provider.FoundryProtocolResponses} {
+ for _, status := range []int{400, 401, 403, 404, 429, 500} {
+ t.Run(fmt.Sprintf("%s/%d", protocol, status), func(t *testing.T) {
+ var attempts atomic.Int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ attempts.Add(1)
+ w.WriteHeader(status)
+ _, _ = io.WriteString(w, `{"error":{"code":"DeploymentNotFound","message":"test failure"}}`)
+ }))
+ defer srv.Close()
+ a, err := azure.New(provider.ProviderConfig{BaseURL: srv.URL, Model: "any", APIKey: "k", APIProtocol: protocol})
+ if err != nil {
+ t.Fatal(err)
+ }
+ d := New(tools.NewRegistry(), a, nil, 3)
+ w := NewCaptureWriter()
+ err = d.Run(context.Background(), fakeStrategy{}, strategy.StrategyInput{LastMessage: "hi"}, w)
+ if err == nil {
+ t.Fatal("failed provider returned success")
+ }
+ wantAttempts := int32(1)
+ if protocol == provider.FoundryProtocolAuto && status == 404 {
+ wantAttempts = 2
+ if !strings.Contains(err.Error(), "azure stream status 404") || !strings.Contains(err.Error(), "azure responses status 404") {
+ t.Fatalf("lost operation errors: %v", err)
+ }
+ }
+ if !errors.Is(err, provider.ErrUpstream) || attempts.Load() != wantAttempts || w.RunError() == nil {
+ t.Fatalf("attempts=%d want=%d err=%v", attempts.Load(), wantAttempts, err)
+ }
+ })
+ }
+ }
+}
diff --git a/internal/ai/dispatch/azure_test.go b/internal/ai/dispatch/azure_test.go
new file mode 100644
index 0000000000..0dfd7f3775
--- /dev/null
+++ b/internal/ai/dispatch/azure_test.go
@@ -0,0 +1,144 @@
+package dispatch
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+
+ "github.com/ev-dev-labs/teslasync/internal/ai/provider"
+ "github.com/ev-dev-labs/teslasync/internal/ai/provider/azure"
+ "github.com/ev-dev-labs/teslasync/internal/ai/strategy"
+ "github.com/ev-dev-labs/teslasync/internal/ai/tools"
+)
+
+type chatOnlyAzure struct{ provider.Provider }
+
+func (a chatOnlyAzure) Capabilities() provider.Capabilities {
+ caps := a.Provider.Capabilities()
+ caps.Streaming = false
+ return caps
+}
+
+func TestAzureHelixDispatchRoundTrip(t *testing.T) {
+ for _, tc := range []struct {
+ responses bool
+ protocol string
+ }{
+ {false, provider.FoundryProtocolAuto}, {true, provider.FoundryProtocolAuto},
+ {false, provider.FoundryProtocolChat}, {true, provider.FoundryProtocolResponses},
+ } {
+ responses := tc.responses
+ for _, stream := range []bool{false, true} {
+ t.Run(fmt.Sprintf("protocol=%s/responses=%v/stream=%v", tc.protocol, responses, stream), func(t *testing.T) {
+ model := "model-router"
+ if responses {
+ model = "gpt-chat-latest"
+ }
+ var turns, attempts atomic.Int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ attempts.Add(1)
+ raw, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Error(err)
+ }
+ var body map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &body); err != nil {
+ t.Error(err)
+ }
+ if string(body["model"]) != fmt.Sprintf("%q", model) {
+ t.Errorf("wrong deployment: %s", body["model"])
+ }
+ if r.URL.Path == "/openai/v1/chat/completions" && responses {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = io.WriteString(w, `{"error":{"code":"DeploymentNotFound"}}`)
+ return
+ }
+ want := "/openai/v1/chat/completions"
+ if responses {
+ want = "/openai/v1/responses"
+ }
+ if r.URL.Path != want {
+ t.Errorf("path=%s want=%s", r.URL.Path, want)
+ }
+ turn := turns.Add(1)
+ if turn == 2 {
+ if !strings.Contains(string(raw), "call_ping") || !strings.Contains(string(raw), `\"pong\":\"ok\"`) {
+ t.Errorf("dispatcher failed to replay tool result: %s", raw)
+ }
+ if responses && (!strings.Contains(string(raw), `"encrypted_content":"opaque-state"`) ||
+ !strings.Contains(string(raw), `"phase":"commentary"`) ||
+ strings.Count(string(raw), `"type":"function_call"`) != 1) {
+ t.Errorf("dispatcher lost or duplicated Responses continuation: %s", raw)
+ }
+ }
+ if responses {
+ if string(body["include"]) != `["reasoning.encrypted_content"]` || string(body["store"]) != "false" {
+ t.Errorf("stateless reasoning not requested: %s", raw)
+ }
+ if turn == 1 {
+ _, _ = io.WriteString(w, `{"status":"completed","output":[{"type":"reasoning","id":"rs_1","summary":[],"encrypted_content":"opaque-state"},{"type":"message","id":"msg_1","role":"assistant","phase":"commentary","content":[{"type":"output_text","text":""}]},{"type":"function_call","call_id":"call_ping","name":"ping","arguments":"{}"}]}`)
+ } else {
+ _, _ = io.WriteString(w, `{"status":"completed","output_text":"Verified pong","usage":{"input_tokens":8,"output_tokens":2}}`)
+ }
+
+ } else if stream {
+ w.Header().Set("Content-Type", "text/event-stream")
+ if turn == 1 {
+ _, _ = io.WriteString(w, "data: "+`{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_ping","type":"function","function":{"name":"ping","arguments":"{}"}}]},"finish_reason":"tool_calls"}]}`+"\n\n")
+ } else {
+ _, _ = io.WriteString(w, "data: "+`{"choices":[{"delta":{"content":"Verified pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":8,"completion_tokens":2}}`+"\n\n")
+ }
+ _, _ = io.WriteString(w, "data: [DONE]\n\n")
+ } else if turn == 1 {
+ _, _ = io.WriteString(w, `{"choices":[{"message":{"role":"assistant","tool_calls":[{"id":"call_ping","type":"function","function":{"name":"ping","arguments":"{}"}}]},"finish_reason":"tool_calls"}]}`)
+ } else {
+ _, _ = io.WriteString(w, `{"choices":[{"message":{"role":"assistant","content":"Verified pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":8,"completion_tokens":2}}`)
+ }
+ }))
+ defer srv.Close()
+ a, err := azure.New(provider.ProviderConfig{
+ BaseURL: srv.URL + "/openai/v1", Model: model, APIKey: "k",
+ APIProtocol: tc.protocol,
+ }, azure.WithHTTPClient(srv.Client()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var p provider.Provider = a
+ if !stream {
+ p = chatOnlyAzure{a}
+ }
+ registry := tools.NewRegistry()
+ registry.Register(&pingTool{})
+ d := New(registry, p, nil, 3)
+ w := NewCaptureWriter()
+ err = d.Run(context.Background(), fakeStrategy{tools: []string{"ping"}}, strategy.StrategyInput{LastMessage: "ping"}, w)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !w.Done() || w.RunError() != nil || strings.Join(w.Deltas(), "") != "Verified pong" {
+ t.Fatalf("done=%v error=%v deltas=%v", w.Done(), w.RunError(), w.Deltas())
+ }
+ if len(w.ToolCalls()) != 1 || len(w.ToolResults()["ping"]) != 1 || len(w.ToolErrors()) != 0 {
+ t.Fatalf("calls=%v results=%v errors=%v", w.ToolCalls(), w.ToolResults(), w.ToolErrors())
+ }
+ finish, in, out := w.Completion()
+ if finish != provider.FinishStop || in != 8 || out != 2 {
+ t.Fatalf("completion=%s %d/%d", finish, in, out)
+ }
+ wantAttempts := int32(2)
+ if responses && tc.protocol == provider.FoundryProtocolAuto {
+ wantAttempts = 4
+ }
+ if turns.Load() != 2 || attempts.Load() != wantAttempts {
+ t.Fatalf("turns=%d attempts=%d", turns.Load(), attempts.Load())
+ }
+ })
+ }
+ }
+}
diff --git a/internal/ai/dispatch/dispatch.go b/internal/ai/dispatch/dispatch.go
index 5c16fa1493..5ac92475a9 100644
--- a/internal/ai/dispatch/dispatch.go
+++ b/internal/ai/dispatch/dispatch.go
@@ -408,8 +408,8 @@ type turnResult struct {
}
// completeTurn uses the provider's real streaming path whenever advertised.
-// Any Stream failure that happens before a frame is consumed falls back
-// to Chat (capability drift, Foundry stream 404, etc.).
+// A failure before the first frame may fall back to Chat unless the adapter
+// has finalized protocol negotiation or the caller has cancelled.
func (d *Dispatcher) completeTurn(
ctx context.Context,
req provider.ChatRequest,
@@ -420,10 +420,7 @@ func (d *Dispatcher) completeTurn(
if err == nil {
return turn, "stream", nil
}
- // Fall back to Chat only when Stream refused before any
- // frame — including Azure Foundry 404 DeploymentNotFound
- // on stream:true while the same Chat probe succeeds.
- if consumed {
+ if consumed || errors.Is(err, provider.ErrStreamFinal) || ctx.Err() != nil {
return turnResult{}, "stream", err
}
}
@@ -454,6 +451,8 @@ func (d *Dispatcher) streamTurn(
req provider.ChatRequest,
w StreamWriter,
) (turnResult, bool, error) {
+ ctx, cancel := context.WithCancel(ctx)
+ defer cancel()
chunks, err := d.provider.Stream(ctx, req)
if err != nil {
return turnResult{}, false, err
@@ -468,6 +467,7 @@ func (d *Dispatcher) streamTurn(
done := false
var finishReason string
var inputTokens, outputTokens int
+ var providerState json.RawMessage
for chunk := range chunks {
consumed = true
switch {
@@ -488,15 +488,20 @@ func (d *Dispatcher) streamTurn(
finishReason = chunk.FinishReason
inputTokens = chunk.InputTokens
outputTokens = chunk.OutputTokens
+ providerState = chunk.ProviderState
}
}
+ if err := ctx.Err(); err != nil {
+ return turnResult{}, consumed, err
+ }
if !done {
return turnResult{}, consumed, ErrStreamIncomplete
}
return turnResult{
message: provider.Message{
- Role: provider.RoleAssistant,
- Content: content.String(),
+ Role: provider.RoleAssistant,
+ Content: content.String(),
+ ProviderState: providerState,
},
toolCalls: toolCalls,
finishReason: finishReason,
diff --git a/internal/ai/features/registry.go b/internal/ai/features/registry.go
index 3267b61c37..a412e68c3a 100644
--- a/internal/ai/features/registry.go
+++ b/internal/ai/features/registry.go
@@ -1610,6 +1610,22 @@ var Registry = map[string]Feature{
PushKinds: []string{},
},
},
+ "alert-pack-builder": {
+ ID: "alert-pack-builder",
+ Name: "Helix custom Alert Packs",
+ Description: "Proposes a goal-based group from supported alert templates. Review and edit the draft before explicit installation. Never changes rules autonomously.",
+ Tier: "A",
+ DefaultOn: false,
+ NeedsTools: true,
+ NeedsStream: true,
+ Routes: RouteSet{
+ Backend: []string{"POST /api/v1/ai/alerts/packs/draft"},
+ Frontend: []string{"/notifications/studio"},
+ UITestIDs: []string{"ai-feature-alert-pack-builder-root"},
+ JobNames: []string{},
+ PushKinds: []string{},
+ },
+ },
// Alert tuning suggestions.
//
// `alert-tuning-suggestions` is an opt-in LLM that proposes a
diff --git a/internal/ai/features/spa_wiring.go b/internal/ai/features/spa_wiring.go
index 0966d7f95b..64fdb52146 100644
--- a/internal/ai/features/spa_wiring.go
+++ b/internal/ai/features/spa_wiring.go
@@ -145,6 +145,13 @@ var SPAWiringIndicatorOnly = []string{
// from the registry, and SPAWiringSelfCheck will enforce both
// constraints automatically.
var SPAWiringTable = []SPAWiring{
+ {
+ FeatureID: "alert-pack-builder",
+ Component: "components/ai/AIAlertPackBuilder.tsx",
+ Endpoint: "POST /api/v1/ai/alerts/packs/draft",
+ Render: RenderProposal,
+ BaselineFormHandoff: "/notifications/studio",
+ },
{
FeatureID: "alert-message-template-suggestion",
Component: "components/ai/AIAlertMessageTemplateSuggestion.tsx",
diff --git a/internal/ai/provider/azure/azure.go b/internal/ai/provider/azure/azure.go
index 4ea65b3384..f2a8e7ab5b 100644
--- a/internal/ai/provider/azure/azure.go
+++ b/internal/ai/provider/azure/azure.go
@@ -1,55 +1,7 @@
-// Package azure is the Azure AI [provider.Provider] adapter.
-//
-// Microsoft hosts AI inference behind two distinct surfaces and this
-// adapter supports both via the [provider.ProviderConfig.Flavor] knob.
-// A third URL shape — Azure AI Foundry's OpenAI v1 API — is detected
-// from the base URL (hostname services.ai.azure.com or a path of
-// /openai/v1) and overrides flavor-based routing so pasting the
-// Foundry portal snippet does not 404:
-//
-// 1. Azure OpenAI Service ([provider.AzureFlavorOpenAI], the default).
-// Hosts the OpenAI model family (gpt-4o, gpt-4-turbo,
-// text-embedding-3-*, etc.). Routes by *deployment name* in the URL
-// path:
-//
-// {base_url}/openai/deployments/{deployment}/chat/completions
-// ?api-version={version}
-//
-// where {base_url} is the resource endpoint
-// (https://{resource}.openai.azure.com). The request body MUST
-// omit the "model" field — Azure rejects requests where the body
-// model disagrees with the deployment.
-//
-// 2. Azure AI Foundry / Inference API ([provider.AzureFlavorFoundry]).
-// The older unified multi-vendor surface:
-//
-// {base_url}/chat/completions?api-version={version}
-// {base_url}/embeddings?api-version={version}
-//
-// Routes by *model* in the request body.
-//
-// 3. Azure AI Foundry OpenAI v1 (auto-detected). Portal snippet:
-//
-// base_url https://{resource}.services.ai.azure.com/openai/v1
-// model {deployment_name} (e.g. gpt-5.6-sol)
-//
-// {base_url}/chat/completions (no api-version query)
-// {base_url}/embeddings
-//
-// Body includes "model". Auth sends api-key and
-// Authorization: Bearer (the OpenAI SDK path). Newer models
-// use max_completion_tokens instead of max_tokens.
-//
-// Classic flavors still share:
-// - Auth: "api-key: {key}" header (NOT Authorization Bearer; that
-// reserved name is used by the Microsoft Entra ID auth path).
-// - Required "?api-version=" query parameter.
-// - The OpenAI Chat Completions JSON envelope (messages, tools,
-// tool_calls, SSE streaming format).
-//
-// The wire types here mirror openai/openai.go because the JSON
-// envelope is identical. They are re-declared rather than imported so
-// either adapter can drift independently if Azure ever breaks parity.
+// Package azure implements Microsoft Foundry OpenAI v1.
+// Auto tries Chat Completions then one Responses fallback on a structured
+// unsupported-operation / not-found error. Explicit protocols never negotiate.
+// https://learn.microsoft.com/en-us/azure/foundry/openai/how-to/responses
package azure
import (
@@ -62,7 +14,6 @@ import (
"io"
"net/http"
"net/url"
- "path"
"strings"
"time"
@@ -73,7 +24,7 @@ import (
const (
defaultTimeout = 120 * time.Second
streamSentinel = "[DONE]"
- streamPrefixData = "data: "
+ streamPrefixData = "data:"
)
// Adapter is the Azure AI [provider.Provider]. Construct via
@@ -101,10 +52,8 @@ func WithHTTPClient(c *http.Client) Option {
// error).
// - cfg.BaseURL fails to parse as a URL.
//
-// Empty Flavor / APIVersion are filled from [provider.DefaultAzureFlavor]
-// and [provider.DefaultAzureAPIVersion] respectively. Flavor must be
-// one of [provider.AzureFlavorOpenAI] or [provider.AzureFlavorFoundry];
-// any other value is rejected.
+// Resource roots are normalized to /openai/v1. Other API paths are rejected,
+// rather than silently changing the target surface.
func New(cfg provider.ProviderConfig, opts ...Option) (*Adapter, error) {
if strings.TrimSpace(cfg.BaseURL) == "" {
return nil, fmt.Errorf("azure: empty base_url")
@@ -112,21 +61,27 @@ func New(cfg provider.ProviderConfig, opts ...Option) (*Adapter, error) {
if strings.TrimSpace(cfg.APIKey) == "" {
return nil, fmt.Errorf("azure: empty api_key")
}
- if _, err := url.Parse(cfg.BaseURL); err != nil {
+ u, err := url.Parse(strings.TrimSpace(cfg.BaseURL))
+ if err != nil {
return nil, fmt.Errorf("azure: parse base_url: %w", err)
}
- if cfg.APIVersion == "" {
- cfg.APIVersion = provider.DefaultAzureAPIVersion
+ if (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
+ return nil, fmt.Errorf("azure: base_url must be a Foundry resource URL without credentials, query or fragment")
+ }
+ switch strings.TrimRight(u.Path, "/") {
+ case "", "/openai/v1":
+ u.Path = "/openai/v1"
+ default:
+ return nil, fmt.Errorf("azure: use the Foundry /openai/v1 base URL; other API paths are not supported")
}
- if cfg.Flavor == "" {
- cfg.Flavor = provider.DefaultAzureFlavor
+ cfg.BaseURL = u.String()
+ if cfg.APIProtocol == "" {
+ cfg.APIProtocol = provider.FoundryProtocolAuto
}
- switch cfg.Flavor {
- case provider.AzureFlavorOpenAI, provider.AzureFlavorFoundry:
- // ok
+ switch cfg.APIProtocol {
+ case provider.FoundryProtocolAuto, provider.FoundryProtocolChat, provider.FoundryProtocolResponses:
default:
- return nil, fmt.Errorf("azure: unknown flavor %q (want %q or %q)",
- cfg.Flavor, provider.AzureFlavorOpenAI, provider.AzureFlavorFoundry)
+ return nil, fmt.Errorf("azure: unknown api_protocol %q", cfg.APIProtocol)
}
a := &Adapter{cfg: cfg}
for _, opt := range opts {
@@ -143,10 +98,7 @@ func Builder(cfg provider.ProviderConfig) (provider.Provider, error) { return Ne
func (a *Adapter) Name() string { return provider.NameAzure }
-// Both Azure flavors support tools and streaming. Embeddings are
-// advertised true; the per-call
-// path returns an error when no embedding deployment / model is
-// configured.
+// Responses streaming is buffered; Chat Completions uses native SSE.
func (a *Adapter) Capabilities() provider.Capabilities {
return provider.Capabilities{
Tools: true,
@@ -157,11 +109,29 @@ func (a *Adapter) Capabilities() provider.Capabilities {
}
func (a *Adapter) Chat(ctx context.Context, req provider.ChatRequest) (*provider.ChatResponse, error) {
+ if a.cfg.APIProtocol == provider.FoundryProtocolResponses {
+ return a.chatViaResponses(ctx, req)
+ }
+ out, err := a.doChatCompletions(ctx, req)
+ if err == nil {
+ return out, nil
+ }
+ if a.cfg.APIProtocol == provider.FoundryProtocolAuto && canTryResponses(err) && ctx.Err() == nil {
+ fallback, fallbackErr := a.chatViaResponses(ctx, req)
+ if fallbackErr != nil {
+ return nil, errors.Join(err, fallbackErr)
+ }
+ return fallback, nil
+ }
+ return nil, err
+}
+
+func (a *Adapter) doChatCompletions(ctx context.Context, req provider.ChatRequest) (*provider.ChatResponse, error) {
endpoint, modelInBody, err := a.chatEndpoint(req)
if err != nil {
return nil, err
}
- body, err := encodeChatRequest(req, modelInBody, false, a.usesOpenAIV1())
+ body, err := encodeChatRequest(req, modelInBody, false)
if err != nil {
return nil, err
}
@@ -171,26 +141,41 @@ func (a *Adapter) Chat(ctx context.Context, req provider.ChatRequest) (*provider
}
resp, err := a.client.Do(httpReq)
if err != nil {
- return nil, fmt.Errorf("%w: azure chat: %v", provider.ErrUpstream, err)
+ return nil, fmt.Errorf("%w: azure chat: %w", provider.ErrUpstream, err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
- raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
- return nil, fmt.Errorf("%w: azure chat status %d: %s", provider.ErrUpstream, resp.StatusCode, string(raw))
+ raw, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
+ if readErr != nil {
+ return nil, fmt.Errorf("%w: azure chat error response: %w", provider.ErrUpstream, readErr)
+ }
+ return nil, newOperationError("chat", resp.StatusCode, raw)
}
var wire azureChatResponse
if err := json.NewDecoder(resp.Body).Decode(&wire); err != nil {
- return nil, fmt.Errorf("%w: azure chat decode: %v", provider.ErrUpstream, err)
+ return nil, fmt.Errorf("%w: azure chat decode: %w", provider.ErrUpstream, err)
}
- return wire.toChatResponse(), nil
+ return wire.toChatResponse()
}
-func (a *Adapter) Stream(ctx context.Context, req provider.ChatRequest) (<-chan provider.Chunk, error) {
+func (a *Adapter) Stream(ctx context.Context, req provider.ChatRequest) (chunks <-chan provider.Chunk, err error) {
+ defer func() {
+ if err != nil {
+ err = errors.Join(provider.ErrStreamFinal, err)
+ }
+ }()
+ if a.cfg.APIProtocol == provider.FoundryProtocolResponses {
+ resp, err := a.chatViaResponses(ctx, req)
+ if err != nil {
+ return nil, err
+ }
+ return chatResponseAsStream(ctx, resp), nil
+ }
endpoint, modelInBody, err := a.chatEndpoint(req)
if err != nil {
return nil, err
}
- body, err := encodeChatRequest(req, modelInBody, true, a.usesOpenAIV1())
+ body, err := encodeChatRequest(req, modelInBody, true)
if err != nil {
return nil, err
}
@@ -201,22 +186,21 @@ func (a *Adapter) Stream(ctx context.Context, req provider.ChatRequest) (<-chan
httpReq.Header.Set("Accept", "text/event-stream")
resp, err := a.client.Do(httpReq)
if err != nil {
- return nil, fmt.Errorf("%w: azure stream: %v", provider.ErrUpstream, err)
+ return nil, fmt.Errorf("%w: azure stream: %w", provider.ErrUpstream, err)
}
if resp.StatusCode/100 != 2 {
- raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+ raw, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
_ = resp.Body.Close()
- streamErr := fmt.Errorf("%w: azure stream status %d: %s", provider.ErrUpstream, resp.StatusCode, string(raw))
- // Foundry OpenAI v1 (especially model-router) accepts the
- // same chat/completions URL for non-stream Chat that
- // Validate uses, but returns 404 DeploymentNotFound when
- // the body has stream:true. Retry as Chat and synthesize
- // chunks so Helix still completes.
- if resp.StatusCode == http.StatusNotFound {
- chatResp, chatErr := a.Chat(ctx, req)
- if chatErr == nil {
- return chatResponseAsStream(ctx, chatResp), nil
+ if readErr != nil {
+ return nil, fmt.Errorf("%w: azure stream error response: %w", provider.ErrUpstream, readErr)
+ }
+ streamErr := newOperationError("stream", resp.StatusCode, raw)
+ if a.cfg.APIProtocol == provider.FoundryProtocolAuto && canTryResponses(streamErr) && ctx.Err() == nil {
+ chatResp, fallbackErr := a.chatViaResponses(ctx, req)
+ if fallbackErr != nil {
+ return nil, errors.Join(streamErr, fallbackErr)
}
+ return chatResponseAsStream(ctx, chatResp), nil
}
return nil, streamErr
}
@@ -251,34 +235,27 @@ func chatResponseAsStream(ctx context.Context, resp *provider.ChatResponse) <-ch
}
}
send(ctx, out, provider.Chunk{
- Done: true,
- FinishReason: finish,
- InputTokens: resp.InputTokens,
- OutputTokens: resp.OutputTokens,
+ Done: true,
+ FinishReason: finish,
+ InputTokens: resp.InputTokens,
+ OutputTokens: resp.OutputTokens,
+ ProviderState: resp.Message.ProviderState,
})
}()
return out
}
-// Embed uses the Azure embeddings route. URL shape depends on flavor:
-//
-// - OpenAI flavor: {base}/openai/deployments/{depl}/embeddings?api-version=...
-// - Foundry flavor: {base}/embeddings?api-version=... (model in body)
+// Embed always uses Foundry v1 embeddings, independently of the chat protocol.
func (a *Adapter) Embed(ctx context.Context, req provider.EmbedRequest) (*provider.EmbedResponse, error) {
identity := a.embedIdentity(req)
if identity == "" {
return nil, fmt.Errorf("azure: empty embedding deployment / model")
}
- endpoint, err := a.embedURL(identity)
+ endpoint, err := a.buildOpenAIV1URL("embeddings")
if err != nil {
return nil, err
}
- payload := map[string]any{"input": req.Input}
- if a.cfg.Flavor == provider.AzureFlavorFoundry || a.usesOpenAIV1() {
- // Foundry and OpenAI v1 route embeddings by model in the
- // request body, the same way OpenAI's public endpoint does.
- payload["model"] = identity
- }
+ payload := map[string]any{"input": req.Input, "model": identity}
body, err := json.Marshal(payload)
if err != nil {
return nil, err
@@ -289,7 +266,7 @@ func (a *Adapter) Embed(ctx context.Context, req provider.EmbedRequest) (*provid
}
resp, err := a.client.Do(httpReq)
if err != nil {
- return nil, fmt.Errorf("%w: azure embed: %v", provider.ErrUpstream, err)
+ return nil, fmt.Errorf("%w: azure embed: %w", provider.ErrUpstream, err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
@@ -306,7 +283,7 @@ func (a *Adapter) Embed(ctx context.Context, req provider.EmbedRequest) (*provid
} `json:"usage"`
}
if err := json.NewDecoder(resp.Body).Decode(&wire); err != nil {
- return nil, fmt.Errorf("%w: azure embed decode: %v", provider.ErrUpstream, err)
+ return nil, fmt.Errorf("%w: azure embed decode: %w", provider.ErrUpstream, err)
}
out := &provider.EmbedResponse{
Vectors: make([][]float32, len(wire.Data)),
@@ -320,172 +297,33 @@ func (a *Adapter) Embed(ctx context.Context, req provider.EmbedRequest) (*provid
return out, nil
}
-// chatEndpoint returns the absolute chat URL for this request and a
-// boolean indicating whether the request body should include the
-// "model" field. The body-model behaviour differs between flavors:
-//
-// - OpenAI flavor: deployment encoded in URL → omit body model.
-// - Foundry flavor: shared endpoint → include body model so the
-// server can route to the right backing model.
func (a *Adapter) chatEndpoint(req provider.ChatRequest) (endpoint string, modelInBody string, err error) {
- if a.usesOpenAIV1() {
- identity := a.chatDeployment(req)
- if identity == "" {
- return "", "", fmt.Errorf("azure: empty v1 model")
- }
- u, err := a.buildOpenAIV1URL("chat", "completions")
- if err != nil {
- return "", "", err
- }
- return u, identity, nil
- }
- switch a.cfg.Flavor {
- case provider.AzureFlavorFoundry:
- // Model identity for body — fall back to cfg.Model when the
- // caller does not pin a model. dispatch currently does not
- // pin one, so cfg.Model is the routing key in practice.
- identity := req.Model
- if identity == "" {
- identity = a.cfg.Model
- }
- if identity == "" {
- return "", "", fmt.Errorf("azure: empty foundry model")
- }
- u, err := a.buildURL("chat", "completions")
- if err != nil {
- return "", "", err
- }
- return u, identity, nil
- default: // AzureFlavorOpenAI
- deployment := a.chatDeployment(req)
- if deployment == "" {
- return "", "", fmt.Errorf("azure: empty chat deployment")
- }
- u, err := a.buildURL("openai", "deployments", deployment, "chat", "completions")
- if err != nil {
- return "", "", err
- }
- return u, "", nil
+ identity := a.chatDeployment(req)
+ if identity == "" {
+ return "", "", fmt.Errorf("azure: empty Foundry deployment name")
}
+ u, err := a.buildOpenAIV1URL("chat", "completions")
+ return u, identity, err
}
-// chatDeployment picks the OpenAI-flavor chat deployment name for a
-// request. Per-request Model > cfg.Deployment > cfg.Model. The third
-// fallback honours the common case where the user named their Azure
-// deployment after the model identifier and stored it in the single
-// "model" field.
func (a *Adapter) chatDeployment(req provider.ChatRequest) string {
if req.Model != "" {
return req.Model
}
- if a.cfg.Deployment != "" {
- return a.cfg.Deployment
- }
return a.cfg.Model
}
-// embedIdentity is the model-or-deployment string used by the embed
-// path. For the OpenAI flavor it becomes the URL deployment segment;
-// for Foundry it goes into the request body. Per-request Model >
-// cfg.EmbeddingDeployment > cfg.EmbeddingModel.
func (a *Adapter) embedIdentity(req provider.EmbedRequest) string {
if req.Model != "" {
return req.Model
}
- if a.cfg.EmbeddingDeployment != "" {
- return a.cfg.EmbeddingDeployment
- }
return a.cfg.EmbeddingModel
}
-// embedURL builds the absolute embeddings URL for the configured
-// flavor. identity is only used by the OpenAI flavor (URL-routed).
-func (a *Adapter) embedURL(identity string) (string, error) {
- if a.usesOpenAIV1() {
- return a.buildOpenAIV1URL("embeddings")
- }
- switch a.cfg.Flavor {
- case provider.AzureFlavorFoundry:
- return a.buildURL("embeddings")
- default:
- return a.buildURL("openai", "deployments", identity, "embeddings")
- }
-}
+const defaultMaxCompletionTokens = 8192
-// isAzureOpenAIV1 reports whether baseURL is Azure AI Foundry's
-// OpenAI-compatible v1 surface. Detected from:
-// - path containing /openai/v1 (the portal "endpoint" field)
-// - host *.services.ai.azure.com (Foundry AI Services resource)
-func isAzureOpenAIV1(baseURL string) bool {
- u, err := url.Parse(strings.TrimSpace(baseURL))
- if err != nil {
- return false
- }
- p := strings.ToLower(path.Clean(u.Path))
- if strings.Contains(p, "/openai/v1") {
- return true
- }
- return strings.Contains(strings.ToLower(u.Hostname()), "services.ai.azure.com")
-}
-
-func (a *Adapter) usesOpenAIV1() bool {
- return isAzureOpenAIV1(a.cfg.BaseURL)
-}
-
-// buildOpenAIV1URL joins BaseURL (ensuring /openai/v1) with extra
-// segments and omits api-version. Foundry's v1 GA API 404s when the
-// classic Azure OpenAI api-version (2024-10-21) is attached.
func (a *Adapter) buildOpenAIV1URL(segments ...string) (string, error) {
- u, err := url.Parse(a.cfg.BaseURL)
- if err != nil {
- return "", fmt.Errorf("azure: parse base_url: %w", err)
- }
- basePath := strings.TrimRight(u.Path, "/")
- if !strings.Contains(strings.ToLower(path.Clean("/"+strings.TrimPrefix(basePath, "/"))), "/openai/v1") {
- if basePath == "" || basePath == "/" {
- basePath = "/openai/v1"
- } else {
- basePath = path.Join(basePath, "openai", "v1")
- }
- }
- if !strings.HasPrefix(basePath, "/") {
- basePath = "/" + basePath
- }
- parts := []string{basePath}
- for _, s := range segments {
- parts = append(parts, url.PathEscape(s))
- }
- u.Path = path.Join(parts...)
- if !strings.HasPrefix(u.Path, "/") {
- u.Path = "/" + u.Path
- }
- u.RawQuery = ""
- return u.String(), nil
-}
-
-// buildURL composes BaseURL + path segments + the api-version query
-// parameter using net/url so deployment names with characters that
-// require percent-encoding (rare but legal: digits, dashes, dots,
-// underscores) round-trip correctly. The variadic segments are joined
-// with path.Join after PathEscape so a user-typed deployment name
-// containing a slash cannot escape the intended sub-tree.
-func (a *Adapter) buildURL(segments ...string) (string, error) {
- u, err := url.Parse(a.cfg.BaseURL)
- if err != nil {
- return "", fmt.Errorf("azure: parse base_url: %w", err)
- }
- escaped := make([]string, 0, len(segments)+1)
- if u.Path != "" {
- escaped = append(escaped, u.Path)
- }
- for _, s := range segments {
- escaped = append(escaped, url.PathEscape(s))
- }
- u.Path = path.Join(escaped...)
- q := u.Query()
- q.Set("api-version", a.cfg.APIVersion)
- u.RawQuery = q.Encode()
- return u.String(), nil
+ return url.JoinPath(a.cfg.BaseURL, segments...)
}
func (a *Adapter) newRequest(ctx context.Context, method, urlStr string, body io.Reader) (*http.Request, error) {
@@ -494,13 +332,8 @@ func (a *Adapter) newRequest(ctx context.Context, method, urlStr string, body io
return nil, err
}
req.Header.Set("Content-Type", "application/json")
- // Azure uses the case-sensitive "api-key" header. The "Authorization:
- // Bearer …" form is reserved for Microsoft Entra ID token auth, which
- // this adapter does not yet support (V1 is api-key only).
req.Header.Set("api-key", a.cfg.APIKey)
- if a.usesOpenAIV1() {
- req.Header.Set("Authorization", "Bearer "+a.cfg.APIKey)
- }
+ req.Header.Set("Authorization", "Bearer "+a.cfg.APIKey)
return req, nil
}
@@ -516,8 +349,6 @@ type azureChatRequest struct {
Messages []azureWireMsg `json:"messages"`
Tools []azureWireTool `json:"tools,omitempty"`
Stream bool `json:"stream,omitempty"`
- Temperature float32 `json:"temperature,omitempty"`
- MaxTokens int `json:"max_tokens,omitempty"`
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
}
@@ -534,6 +365,7 @@ type azureWireMsg struct {
Name string `json:"name,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
ToolCalls []azureWireToolCall `json:"tool_calls,omitempty"`
+ Refusal string `json:"refusal,omitempty"`
}
type azureWireToolCall struct {
@@ -574,6 +406,7 @@ type azureStreamFrame struct {
Delta struct {
Content string `json:"content,omitempty"`
ToolCalls []azureWireToolCall `json:"tool_calls,omitempty"`
+ Refusal string `json:"refusal,omitempty"`
} `json:"delta"`
FinishReason string `json:"finish_reason,omitempty"`
} `json:"choices"`
@@ -591,11 +424,7 @@ type azureStreamFrame struct {
} `json:"error,omitempty"`
}
-// encodeChatRequest serialises a [provider.ChatRequest] into the
-// Azure JSON envelope. modelInBody is non-empty only for the Foundry
-// flavor; for Azure OpenAI Service the body MUST omit the model
-// field (the deployment name in the URL is the routing key).
-func encodeChatRequest(req provider.ChatRequest, modelInBody string, stream bool, v1 bool) ([]byte, error) {
+func encodeChatRequest(req provider.ChatRequest, modelInBody string, stream bool) ([]byte, error) {
wireMsgs := make([]azureWireMsg, 0, len(req.Messages))
for _, m := range req.Messages {
wm := azureWireMsg{Role: m.Role, Content: m.Content, Name: m.Name, ToolCallID: m.ToolID}
@@ -628,76 +457,88 @@ func encodeChatRequest(req provider.ChatRequest, modelInBody string, stream bool
wireTools = append(wireTools, wt)
}
wire := azureChatRequest{
- Model: modelInBody,
- Messages: wireMsgs,
- Tools: wireTools,
- Stream: stream,
- Temperature: req.Temperature,
+ Model: modelInBody,
+ Messages: wireMsgs,
+ Tools: wireTools,
+ Stream: stream,
}
- if v1 {
- wire.MaxCompletionTokens = req.MaxTokens
- } else {
- wire.MaxTokens = req.MaxTokens
+ n := req.MaxTokens
+ if n <= 0 {
+ n = defaultMaxCompletionTokens
}
+ wire.MaxCompletionTokens = n
if len(wireTools) == 0 {
wire.Tools = nil
}
return json.Marshal(wire)
}
-func (r *azureChatResponse) toChatResponse() *provider.ChatResponse {
+func (r *azureChatResponse) toChatResponse() (*provider.ChatResponse, error) {
out := &provider.ChatResponse{
InputTokens: r.Usage.PromptTokens,
OutputTokens: r.Usage.CompletionTokens,
FinishReason: provider.FinishStop,
}
if len(r.Choices) == 0 {
- return out
+ return nil, fmt.Errorf("%w: azure chat returned no choices", provider.ErrUpstream)
}
c := r.Choices[0]
+ if c.Message.Refusal != "" {
+ return nil, fmt.Errorf("%w: azure chat refusal: %s", provider.ErrUpstream, c.Message.Refusal)
+ }
out.Message = provider.Message{
Role: c.Message.Role,
Content: c.Message.Content,
Name: c.Message.Name,
ToolID: c.Message.ToolCallID,
}
- switch c.FinishReason {
- case "stop":
- out.FinishReason = provider.FinishStop
- case "length":
- out.FinishReason = provider.FinishLength
- case "tool_calls":
- out.FinishReason = provider.FinishToolCalls
- case "content_filter":
- out.FinishReason = provider.FinishContentFilter
+ out.FinishReason = provider.NormalizeFinishReason(c.FinishReason)
+ if out.FinishReason == "" {
+ return nil, fmt.Errorf("%w: azure chat returned unknown finish reason %q", provider.ErrUpstream, c.FinishReason)
}
for _, tc := range c.Message.ToolCalls {
+ if out.FinishReason == provider.FinishLength || out.FinishReason == provider.FinishContentFilter {
+ break
+ }
out.ToolCalls = append(out.ToolCalls, provider.ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Arguments: json.RawMessage(tc.Function.Arguments),
})
}
- return out
+ if err := validateCompletedTools(out.ToolCalls, out.FinishReason); err != nil {
+ return nil, err
+ }
+ if out.FinishReason == provider.FinishStop && strings.TrimSpace(out.Message.Content) == "" {
+ return nil, fmt.Errorf("%w: azure chat completed without content", provider.ErrUpstream)
+ }
+ return out, nil
}
func relayStream(ctx context.Context, body io.ReadCloser, out chan<- provider.Chunk) {
defer close(out)
defer body.Close()
+ stopClose := context.AfterFunc(ctx, func() { _ = body.Close() })
+ defer stopClose()
var toolCalls provider.ToolCallAccumulator
var finishReason string
var inputTokens, outputTokens int
+ var hasContent bool
emitTerminal := func() {
calls := toolCalls.Calls()
if finishReason == "" {
- if len(calls) > 0 {
- send(ctx, out, provider.Chunk{Err: fmt.Errorf("%w: azure stream ended without a tool_calls finish reason", provider.ErrUpstream)})
- return
- }
- finishReason = provider.FinishStop
+ send(ctx, out, provider.Chunk{Err: fmt.Errorf("%w: azure stream ended without a finish reason", provider.ErrUpstream)})
+ return
+ }
+ if finishReason == provider.FinishLength || finishReason == provider.FinishContentFilter {
+ calls = nil
}
- if len(calls) > 0 && finishReason != provider.FinishToolCalls {
- send(ctx, out, provider.Chunk{Err: fmt.Errorf("%w: azure emitted tool fragments with finish reason %q", provider.ErrUpstream, finishReason)})
+ if err := validateCompletedTools(calls, finishReason); err != nil {
+ send(ctx, out, provider.Chunk{Err: err})
+ return
+ }
+ if finishReason == provider.FinishStop && !hasContent {
+ send(ctx, out, provider.Chunk{Err: fmt.Errorf("%w: azure stream completed without content", provider.ErrUpstream)})
return
}
if finishReason == provider.FinishToolCalls {
@@ -758,7 +599,12 @@ func relayStream(ctx context.Context, body io.ReadCloser, out chan<- provider.Ch
continue
}
ch := frame.Choices[0]
+ if ch.Delta.Refusal != "" {
+ send(ctx, out, provider.Chunk{Err: fmt.Errorf("%w: azure stream refusal: %s", provider.ErrUpstream, ch.Delta.Refusal)})
+ return
+ }
if ch.Delta.Content != "" {
+ hasContent = hasContent || strings.TrimSpace(ch.Delta.Content) != ""
send(ctx, out, provider.Chunk{Delta: ch.Delta.Content})
}
for _, tc := range ch.Delta.ToolCalls {
@@ -773,19 +619,36 @@ func relayStream(ctx context.Context, body io.ReadCloser, out chan<- provider.Ch
}
}
if err := scanner.Err(); err != nil && !errors.Is(err, io.EOF) {
- send(ctx, out, provider.Chunk{Err: fmt.Errorf("%w: azure stream read: %v", provider.ErrUpstream, err)})
+ send(ctx, out, provider.Chunk{Err: fmt.Errorf("%w: azure stream read: %w", provider.ErrUpstream, err)})
return
}
- if finishReason != "" {
- emitTerminal()
- }
+ emitTerminal()
}
func send(ctx context.Context, out chan<- provider.Chunk, c provider.Chunk) {
+ if ctx.Err() != nil {
+ return
+ }
select {
case <-ctx.Done():
case out <- c:
}
}
+func validateCompletedTools(calls []provider.ToolCall, finish string) error {
+ if (len(calls) > 0) != (finish == provider.FinishToolCalls) {
+ return fmt.Errorf("%w: azure tool calls inconsistent with finish reason %q", provider.ErrUpstream, finish)
+ }
+ seen := make(map[string]bool, len(calls))
+ for _, call := range calls {
+ var args map[string]json.RawMessage
+ if strings.TrimSpace(call.ID) == "" || strings.TrimSpace(call.Name) == "" || seen[call.ID] ||
+ json.Unmarshal(call.Arguments, &args) != nil || args == nil {
+ return fmt.Errorf("%w: azure invalid or duplicate function call", provider.ErrUpstream)
+ }
+ seen[call.ID] = true
+ }
+ return nil
+}
+
var _ provider.Provider = (*Adapter)(nil)
diff --git a/internal/ai/provider/azure/azure_test.go b/internal/ai/provider/azure/azure_test.go
index 2e1f25d0bb..357c7dc338 100644
--- a/internal/ai/provider/azure/azure_test.go
+++ b/internal/ai/provider/azure/azure_test.go
@@ -8,759 +8,236 @@ import (
"net/http"
"net/http/httptest"
"strings"
+ "sync/atomic"
"testing"
"github.com/ev-dev-labs/teslasync/internal/ai/provider"
)
-// newAdapter builds an [Adapter] backed by an httptest server. flavor
-// selects between OpenAI Service and Foundry. The test handler runs
-// against srv.URL so URL composition is exercised end-to-end.
-func newAdapter(t *testing.T, flavor string, h http.Handler) *Adapter {
- t.Helper()
- srv := httptest.NewServer(h)
- t.Cleanup(srv.Close)
- a, err := New(provider.ProviderConfig{
- BaseURL: srv.URL,
- Model: "gpt-4o-mini",
- EmbeddingModel: "text-embedding-3-small",
- APIKey: "azure-test-key",
- APIVersion: "2024-10-21",
- Flavor: flavor,
- }, WithHTTPClient(srv.Client()))
- if err != nil {
- t.Fatalf("New: %v", err)
- }
- return a
-}
-
-// TestNew_Validation covers the construction guards: missing
-// base_url, missing api_key, unknown flavor, and the happy default.
-func TestNew_Validation(t *testing.T) {
- t.Parallel()
- cases := []struct {
- name string
- cfg provider.ProviderConfig
- wantErr string // substring; "" means no error
- }{
- {
- name: "empty base_url",
- cfg: provider.ProviderConfig{APIKey: "k"},
- wantErr: "empty base_url",
- },
- {
- name: "empty api_key",
- cfg: provider.ProviderConfig{BaseURL: "https://x.openai.azure.com"},
- wantErr: "empty api_key",
- },
- {
- name: "unknown flavor",
- cfg: provider.ProviderConfig{
- BaseURL: "https://x.openai.azure.com",
- APIKey: "k",
- Flavor: "bogus",
- },
- wantErr: "unknown flavor",
- },
- {
- name: "happy default flavor + version",
- cfg: provider.ProviderConfig{
- BaseURL: "https://x.openai.azure.com",
- APIKey: "k",
- },
- },
- }
- for _, c := range cases {
- c := c
- t.Run(c.name, func(t *testing.T) {
- t.Parallel()
- a, err := New(c.cfg)
- if c.wantErr == "" {
- if err != nil {
- t.Fatalf("New: %v", err)
- }
- if a.cfg.Flavor != provider.AzureFlavorOpenAI {
- t.Errorf("default flavor = %q, want %q", a.cfg.Flavor, provider.AzureFlavorOpenAI)
- }
- if a.cfg.APIVersion != provider.DefaultAzureAPIVersion {
- t.Errorf("default api_version = %q, want %q", a.cfg.APIVersion, provider.DefaultAzureAPIVersion)
- }
- return
- }
- if err == nil || !strings.Contains(err.Error(), c.wantErr) {
- t.Fatalf("err = %v, want substring %q", err, c.wantErr)
- }
- })
- }
-}
-
-func TestAzure_Name(t *testing.T) {
- t.Parallel()
- a := newAdapter(t, provider.AzureFlavorOpenAI, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
- if a.Name() != provider.NameAzure {
- t.Fatalf("Name = %q, want %q", a.Name(), provider.NameAzure)
- }
-}
-
-// TestOpenAIFlavor_Chat_URLAndAuth asserts the Azure OpenAI Service
-// URL shape, the api-key header (NOT Authorization Bearer), and the
-// body-omits-model invariant.
-func TestOpenAIFlavor_Chat_URLAndAuth(t *testing.T) {
- t.Parallel()
- a := newAdapter(t, provider.AzureFlavorOpenAI, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- // URL: /openai/deployments/{deployment}/chat/completions?api-version=…
- if !strings.HasSuffix(r.URL.Path, "/openai/deployments/gpt-4o-mini/chat/completions") {
- t.Errorf("path=%s", r.URL.Path)
- }
- if got := r.URL.Query().Get("api-version"); got != "2024-10-21" {
- t.Errorf("api-version=%q", got)
+func TestNewFoundryValidation(t *testing.T) {
+ for _, base := range []string{"https://resource.services.ai.azure.com", "https://resource.openai.azure.com/", "https://resource.services.ai.azure.com/openai/v1/"} {
+ a, err := New(provider.ProviderConfig{BaseURL: base, APIKey: "k"})
+ if err != nil {
+ t.Fatal(err)
}
- // Auth: api-key header set, Authorization not set.
- if got := r.Header.Get("api-key"); got != "azure-test-key" {
- t.Errorf("api-key header=%q", got)
+ if !strings.HasSuffix(a.cfg.BaseURL, "/openai/v1") || a.cfg.APIProtocol != provider.FoundryProtocolAuto {
+ t.Fatalf("cfg=%+v", a.cfg)
}
- if got := r.Header.Get("Authorization"); got != "" {
- t.Errorf("Authorization header should be empty, got %q", got)
+ if a.Name() != provider.NameAzure || !a.Capabilities().Tools || !a.Capabilities().Streaming || !a.Capabilities().Embeddings {
+ t.Fatalf("capabilities=%+v", a.Capabilities())
}
- // Body: must NOT include "model" — Azure OpenAI rejects
- // requests where a body model disagrees with the deployment.
- body, _ := io.ReadAll(r.Body)
- var probe map[string]any
- _ = json.Unmarshal(body, &probe)
- if _, has := probe["model"]; has {
- t.Errorf("body should not include model field, got: %s", string(body))
- }
- _, _ = io.WriteString(w, `{"choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":"hi"}}],"usage":{"prompt_tokens":3,"completion_tokens":1}}`)
- }))
- resp, err := a.Chat(context.Background(), provider.ChatRequest{
- Messages: []provider.Message{{Role: provider.RoleUser, Content: "hello"}},
- })
- if err != nil {
- t.Fatalf("Chat: %v", err)
}
- if resp.Message.Content != "hi" {
- t.Fatalf("content=%q", resp.Message.Content)
- }
- if resp.InputTokens != 3 || resp.OutputTokens != 1 {
- t.Fatalf("tokens=%d/%d", resp.InputTokens, resp.OutputTokens)
- }
-}
-
-// TestOpenAIFlavor_DeploymentOverride asserts cfg.Deployment overrides
-// cfg.Model for the URL deployment segment when set.
-func TestOpenAIFlavor_DeploymentOverride(t *testing.T) {
- t.Parallel()
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if !strings.Contains(r.URL.Path, "/openai/deployments/prod-chat-deployment/chat/completions") {
- t.Errorf("path=%s, want prod-chat-deployment in URL", r.URL.Path)
- }
- _, _ = io.WriteString(w, `{"choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":"ok"}}]}`)
- }))
- t.Cleanup(srv.Close)
- a, err := New(provider.ProviderConfig{
- BaseURL: srv.URL,
- Model: "gpt-4o-mini",
- Deployment: "prod-chat-deployment",
- APIKey: "k",
- APIVersion: "2024-10-21",
- Flavor: provider.AzureFlavorOpenAI,
- }, WithHTTPClient(srv.Client()))
- if err != nil {
- t.Fatalf("New: %v", err)
- }
- if _, err := a.Chat(context.Background(), provider.ChatRequest{
- Messages: []provider.Message{{Role: provider.RoleUser, Content: "x"}},
- }); err != nil {
- t.Fatalf("Chat: %v", err)
- }
-}
-
-// TestFoundryFlavor_Chat_URLAndModelInBody asserts the Foundry URL
-// shape (no /openai/deployments/ prefix), the api-key header, and the
-// model-in-body routing.
-func TestFoundryFlavor_Chat_URLAndModelInBody(t *testing.T) {
- t.Parallel()
- a := newAdapter(t, provider.AzureFlavorFoundry, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if !strings.HasSuffix(r.URL.Path, "/chat/completions") || strings.Contains(r.URL.Path, "/deployments/") {
- t.Errorf("foundry path=%s, want /chat/completions without deployments segment", r.URL.Path)
+ for _, base := range []string{"", "not-a-url", "ftp://resource", "https://u:p@resource", "https://resource/models",
+ "https://resource/openai/deployments/a", "https://resource/openai/v10", "https://resource/openai/v1?api-version=old",
+ "https://resource/openai/v1#fragment"} {
+ if _, err := New(provider.ProviderConfig{BaseURL: base, APIKey: "k"}); err == nil {
+ t.Errorf("accepted unsupported endpoint %s", base)
}
- if got := r.URL.Query().Get("api-version"); got != "2024-10-21" {
- t.Errorf("api-version=%q", got)
- }
- if got := r.Header.Get("api-key"); got != "azure-test-key" {
- t.Errorf("api-key header=%q", got)
- }
- body, _ := io.ReadAll(r.Body)
- var probe map[string]any
- _ = json.Unmarshal(body, &probe)
- if got, _ := probe["model"].(string); got != "gpt-4o-mini" {
- t.Errorf("body model=%q, want gpt-4o-mini", got)
- }
- _, _ = io.WriteString(w, `{"choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":"ok"}}]}`)
- }))
- if _, err := a.Chat(context.Background(), provider.ChatRequest{
- Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
- }); err != nil {
- t.Fatalf("Chat: %v", err)
- }
-}
-
-// TestChat_ToolCallsParsed asserts tool-calls survive the JSON
-// envelope round-trip on both flavors (envelope is identical).
-func TestChat_ToolCallsParsed(t *testing.T) {
- t.Parallel()
- a := newAdapter(t, provider.AzureFlavorOpenAI, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- _, _ = io.WriteString(w, `{
- "choices":[{"index":0,"finish_reason":"tool_calls","message":{
- "role":"assistant",
- "tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"PDX\"}"}}]
- }}]
- }`)
- }))
- resp, err := a.Chat(context.Background(), provider.ChatRequest{
- Messages: []provider.Message{{Role: provider.RoleUser, Content: "weather"}},
- Tools: []provider.ToolSpec{{Name: "get_weather", Description: "weather", Parameters: json.RawMessage(`{"type":"object"}`)}},
- })
- if err != nil {
- t.Fatalf("Chat: %v", err)
- }
- if resp.FinishReason != provider.FinishToolCalls {
- t.Fatalf("finish=%q", resp.FinishReason)
- }
- if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].Name != "get_weather" {
- t.Fatalf("tool calls=%+v", resp.ToolCalls)
- }
- if string(resp.ToolCalls[0].Arguments) != `{"city":"PDX"}` {
- t.Fatalf("args=%s", string(resp.ToolCalls[0].Arguments))
}
-}
-
-// TestChat_ContentFilterMappedToFinishReason asserts Azure's content-
-// filter finish_reason maps to the canonical [provider.FinishContentFilter].
-func TestChat_ContentFilterMappedToFinishReason(t *testing.T) {
- t.Parallel()
- a := newAdapter(t, provider.AzureFlavorOpenAI, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- _, _ = io.WriteString(w, `{"choices":[{"index":0,"finish_reason":"content_filter","message":{"role":"assistant","content":""}}]}`)
- }))
- resp, err := a.Chat(context.Background(), provider.ChatRequest{
- Messages: []provider.Message{{Role: provider.RoleUser, Content: "x"}},
- })
- if err != nil {
- t.Fatalf("Chat: %v", err)
+ if _, err := New(provider.ProviderConfig{BaseURL: "https://resource", APIKey: "k", APIProtocol: "bogus"}); err == nil {
+ t.Fatal("accepted unknown protocol")
}
- if resp.FinishReason != provider.FinishContentFilter {
- t.Fatalf("finish=%q, want %q", resp.FinishReason, provider.FinishContentFilter)
- }
-}
-
-// TestChat_UpstreamError surfaces non-2xx response bodies as
-// [provider.ErrUpstream] so the dispatch layer can branch on it.
-func TestChat_UpstreamError(t *testing.T) {
- t.Parallel()
- a := newAdapter(t, provider.AzureFlavorOpenAI, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusUnauthorized)
- _, _ = io.WriteString(w, `{"error":{"code":"401","message":"unauthorized"}}`)
- }))
- _, err := a.Chat(context.Background(), provider.ChatRequest{
- Messages: []provider.Message{{Role: provider.RoleUser, Content: "x"}},
- })
- if !errors.Is(err, provider.ErrUpstream) {
- t.Fatalf("err=%v, want ErrUpstream", err)
+ if _, err := Builder(provider.ProviderConfig{BaseURL: "https://resource"}); err == nil {
+ t.Fatal("accepted missing key")
}
- if !strings.Contains(err.Error(), "401") {
- t.Fatalf("err=%v should preserve status code", err)
+ p, err := Builder(provider.ProviderConfig{BaseURL: "https://resource", APIKey: "k"})
+ if err != nil || p.Name() != provider.NameAzure {
+ t.Fatalf("builder=%v err=%v", p, err)
}
}
-// TestStream_HappyPath relays the standard SSE wire format.
-func TestStream_HappyPath(t *testing.T) {
- t.Parallel()
- a := newAdapter(t, provider.AzureFlavorOpenAI, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", "text/event-stream")
- _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"he\"}}]}\n\n")
- _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"llo\"}}]}\n\n")
- _, _ = io.WriteString(w, "data: {\"choices\":[{\"finish_reason\":\"stop\",\"delta\":{}}],\"usage\":{\"prompt_tokens\":11,\"completion_tokens\":3}}\n\n")
- _, _ = io.WriteString(w, "data: [DONE]\n\n")
- }))
- ch, err := a.Stream(context.Background(), provider.ChatRequest{
- Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
- })
- if err != nil {
- t.Fatalf("Stream: %v", err)
- }
- var content string
- var terminal provider.Chunk
- for c := range ch {
- if c.Err != nil {
- t.Fatalf("chunk err: %v", c.Err)
+func TestFoundryChatFinishReasons(t *testing.T) {
+ for _, finish := range []string{provider.FinishStop, provider.FinishLength, provider.FinishContentFilter} {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = io.WriteString(w, `{"choices":[{"message":{"role":"assistant","content":"text"},"finish_reason":"`+finish+`"}]}`)
+ }))
+ a, err := New(provider.ProviderConfig{BaseURL: srv.URL, APIKey: "k", Model: "any"})
+ if err != nil {
+ t.Fatal(err)
}
- content += c.Delta
- if c.Done {
- terminal = c
+ resp, err := a.Chat(context.Background(), provider.ChatRequest{})
+ srv.Close()
+ if err != nil || resp.FinishReason != finish || resp.Message.Content != "text" {
+ t.Fatalf("finish=%s resp=%+v err=%v", finish, resp, err)
}
}
- if !terminal.Done {
- t.Fatal("expected Done chunk")
- }
- if content != "hello" {
- t.Fatalf("content=%q", content)
- }
- if terminal.FinishReason != provider.FinishStop ||
- terminal.InputTokens != 11 || terminal.OutputTokens != 3 {
- t.Fatalf("terminal = %+v", terminal)
- }
}
-func TestStream_AssemblesToolCallFragments(t *testing.T) {
- t.Parallel()
- a := newAdapter(t, provider.AzureFlavorOpenAI, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", "text/event-stream")
- _, _ = io.WriteString(w, `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"query_drives_recent","arguments":"{\"vehicle_id\":"}}]}}]}`+"\n\n")
- _, _ = io.WriteString(w, `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"7,\"limit\":5}"}}]},"finish_reason":"tool_calls"}]}`+"\n\n")
- }))
- ch, err := a.Stream(context.Background(), provider.ChatRequest{
- Messages: []provider.Message{{Role: provider.RoleUser, Content: "drives"}},
- })
- if err != nil {
- t.Fatalf("Stream: %v", err)
- }
- var calls []provider.ToolCall
- for chunk := range ch {
- if chunk.Err != nil {
- t.Fatalf("chunk err: %v", chunk.Err)
+func TestFoundryChatTokenBudgets(t *testing.T) {
+ for _, budget := range []int{0, 1, 73} {
+ raw, err := encodeChatRequest(provider.ChatRequest{MaxTokens: budget}, "any", false)
+ if err != nil {
+ t.Fatal(err)
}
- if chunk.ToolDelta != nil {
- calls = append(calls, *chunk.ToolDelta)
+ var body azureChatRequest
+ if err := json.Unmarshal(raw, &body); err != nil {
+ t.Fatal(err)
}
- }
- if len(calls) != 1 {
- t.Fatalf("tool calls = %+v, want one assembled call", calls)
- }
- if calls[0].Name != "query_drives_recent" ||
- string(calls[0].Arguments) != `{"vehicle_id":7,"limit":5}` {
- t.Fatalf("assembled call = %+v", calls[0])
- }
-}
-
-// TestStream_MidStreamErrorSurfaced asserts the relay propagates a
-// structured error frame instead of silently swallowing it.
-func TestStream_MidStreamErrorSurfaced(t *testing.T) {
- t.Parallel()
- a := newAdapter(t, provider.AzureFlavorOpenAI, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", "text/event-stream")
- _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"part\"}}]}\n\n")
- _, _ = io.WriteString(w, "data: {\"error\":{\"code\":\"content_filter\",\"message\":\"blocked\"}}\n\n")
- }))
- ch, err := a.Stream(context.Background(), provider.ChatRequest{
- Messages: []provider.Message{{Role: provider.RoleUser, Content: "x"}},
- })
- if err != nil {
- t.Fatalf("Stream: %v", err)
- }
- var sawErr bool
- for c := range ch {
- if c.Err != nil {
- sawErr = true
- if !strings.Contains(c.Err.Error(), "content_filter") {
- t.Errorf("err missing code: %v", c.Err)
- }
+ want := budget
+ if want == 0 {
+ want = defaultMaxCompletionTokens
}
- }
- if !sawErr {
- t.Fatal("expected error chunk to be surfaced")
- }
-}
-
-// TestStream_EmptyChoicesSkipped asserts content-filter / annotation
-// frames with no choices do not stop the stream.
-func TestStream_EmptyChoicesSkipped(t *testing.T) {
- t.Parallel()
- a := newAdapter(t, provider.AzureFlavorOpenAI, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", "text/event-stream")
- _, _ = io.WriteString(w, "data: {\"prompt_filter_results\":[]}\n\n")
- _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\n")
- _, _ = io.WriteString(w, "data: {\"choices\":[]}\n\n")
- _, _ = io.WriteString(w, "data: [DONE]\n\n")
- }))
- ch, err := a.Stream(context.Background(), provider.ChatRequest{
- Messages: []provider.Message{{Role: provider.RoleUser, Content: "x"}},
- })
- if err != nil {
- t.Fatalf("Stream: %v", err)
- }
- var content string
- for c := range ch {
- if c.Err != nil {
- t.Fatalf("chunk err: %v", c.Err)
- }
- content += c.Delta
- }
- if content != "ok" {
- t.Fatalf("content=%q", content)
- }
-}
-
-// TestEmbed_OpenAIFlavor_DeploymentInURL asserts the embedding URL
-// includes the embedding deployment segment.
-func TestEmbed_OpenAIFlavor_DeploymentInURL(t *testing.T) {
- t.Parallel()
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if !strings.Contains(r.URL.Path, "/openai/deployments/embed-deployment/embeddings") {
- t.Errorf("path=%s, want /openai/deployments/embed-deployment/embeddings", r.URL.Path)
+ if body.MaxCompletionTokens != want {
+ t.Fatalf("budget=%d wire=%s", budget, raw)
}
- _, _ = io.WriteString(w, `{"data":[{"embedding":[0.1,0.2],"index":0}],"usage":{"prompt_tokens":5}}`)
- }))
- t.Cleanup(srv.Close)
- a, err := New(provider.ProviderConfig{
- BaseURL: srv.URL,
- Model: "gpt-4o-mini",
- EmbeddingModel: "text-embedding-3-small",
- EmbeddingDeployment: "embed-deployment",
- APIKey: "k",
- Flavor: provider.AzureFlavorOpenAI,
- }, WithHTTPClient(srv.Client()))
- if err != nil {
- t.Fatalf("New: %v", err)
- }
- resp, err := a.Embed(context.Background(), provider.EmbedRequest{Input: []string{"hi"}})
- if err != nil {
- t.Fatalf("Embed: %v", err)
- }
- if len(resp.Vectors) != 1 || len(resp.Vectors[0]) != 2 {
- t.Fatalf("vectors=%v", resp.Vectors)
}
}
-// TestEmbed_FoundryFlavor_ModelInBody asserts the Foundry embeddings
-// path uses /embeddings (no deployment segment) and ships the model
-// in the body.
-func TestEmbed_FoundryFlavor_ModelInBody(t *testing.T) {
- t.Parallel()
- a := newAdapter(t, provider.AzureFlavorFoundry, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if !strings.HasSuffix(r.URL.Path, "/embeddings") || strings.Contains(r.URL.Path, "/deployments/") {
- t.Errorf("path=%s, want /embeddings without deployment segment", r.URL.Path)
- }
- body, _ := io.ReadAll(r.Body)
- var probe map[string]any
- _ = json.Unmarshal(body, &probe)
- if got, _ := probe["model"].(string); got != "text-embedding-3-small" {
- t.Errorf("body model=%q", got)
+func TestExplicitProtocolsNeverNegotiate(t *testing.T) {
+ for _, protocol := range []string{provider.FoundryProtocolChat, provider.FoundryProtocolResponses} {
+ for _, stream := range []bool{false, true} {
+ var calls atomic.Int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ calls.Add(1)
+ want := "/openai/v1/chat/completions"
+ if protocol == provider.FoundryProtocolResponses {
+ want = "/openai/v1/responses"
+ }
+ if r.URL.Path != want || r.URL.RawQuery != "" {
+ t.Errorf("url=%s want=%s", r.URL.String(), want)
+ }
+ w.WriteHeader(404)
+ _, _ = io.WriteString(w, `{"error":{"code":"DeploymentNotFound"}}`)
+ }))
+ a, err := New(provider.ProviderConfig{BaseURL: srv.URL, APIKey: "k", Model: "arbitrary-name", APIProtocol: protocol})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stream {
+ _, err = a.Stream(context.Background(), provider.ChatRequest{})
+ } else {
+ _, err = a.Chat(context.Background(), provider.ChatRequest{})
+ }
+ srv.Close()
+ if !errors.Is(err, provider.ErrUpstream) || calls.Load() != 1 {
+ t.Fatalf("protocol=%s calls=%d err=%v", protocol, calls.Load(), err)
+ }
}
- _, _ = io.WriteString(w, `{"data":[{"embedding":[0.5],"index":0}],"usage":{"prompt_tokens":2}}`)
- }))
- resp, err := a.Embed(context.Background(), provider.EmbedRequest{Input: []string{"hi"}})
- if err != nil {
- t.Fatalf("Embed: %v", err)
- }
- if len(resp.Vectors) != 1 || resp.InputTokens != 2 {
- t.Fatalf("resp=%+v", resp)
- }
-}
-
-// TestEmbed_MissingDeployment fails fast when no embedding deployment
-// or model is configured.
-func TestEmbed_MissingDeployment(t *testing.T) {
- t.Parallel()
- a, err := New(provider.ProviderConfig{
- BaseURL: "https://x.openai.azure.com",
- APIKey: "k",
- })
- if err != nil {
- t.Fatalf("New: %v", err)
- }
- _, err = a.Embed(context.Background(), provider.EmbedRequest{Input: []string{"x"}})
- if err == nil || !strings.Contains(err.Error(), "embedding") {
- t.Fatalf("err=%v, want missing-embedding error", err)
- }
-}
-
-// TestCapabilities sanity-checks the static surface.
-func TestCapabilities(t *testing.T) {
- t.Parallel()
- a := newAdapter(t, provider.AzureFlavorOpenAI, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
- caps := a.Capabilities()
- if !caps.Tools || !caps.Streaming || !caps.Embeddings {
- t.Fatalf("capabilities=%+v", caps)
}
}
-// TestBuilder asserts the registry-compatible factory wires through.
-func TestBuilder(t *testing.T) {
- t.Parallel()
- p, err := Builder(provider.ProviderConfig{
- BaseURL: "https://x.openai.azure.com",
- APIKey: "k",
- })
- if err != nil {
- t.Fatalf("Builder: %v", err)
- }
- if p.Name() != provider.NameAzure {
- t.Fatalf("Name=%q", p.Name())
- }
-}
-
-// TestBuildURL_TrailingSlashSafe covers the net/url composition for
-// edge cases the rubber-duck flagged: trailing slash on base_url and
-// deployment names with characters that need percent-encoding.
-func TestBuildURL_TrailingSlashSafe(t *testing.T) {
- t.Parallel()
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- // Must not produce a double slash.
- if strings.Contains(r.URL.Path, "//") {
- t.Errorf("double slash in path: %s", r.URL.Path)
+func TestFoundryEmbeddingsIndependentOfProtocol(t *testing.T) {
+ for _, protocol := range []string{provider.FoundryProtocolAuto, provider.FoundryProtocolChat, provider.FoundryProtocolResponses} {
+ for _, override := range []string{"", "request-embedding"} {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/openai/v1/embeddings" || r.URL.RawQuery != "" ||
+ r.Header.Get("api-key") != "k" || r.Header.Get("Authorization") != "Bearer k" {
+ t.Errorf("wrong endpoint/auth: %s", r.URL.String())
+ }
+ var body struct {
+ Model string `json:"model"`
+ Input []string `json:"input"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ t.Error(err)
+ }
+ want := "saved-embedding"
+ if override != "" {
+ want = override
+ }
+ if body.Model != want || len(body.Input) != 1 || body.Input[0] != "hi" {
+ t.Errorf("body=%+v", body)
+ }
+ _, _ = io.WriteString(w, `{"data":[{"index":0,"embedding":[0.5,0.25]}],"usage":{"prompt_tokens":2}}`)
+ }))
+ a, err := New(provider.ProviderConfig{BaseURL: srv.URL + "/openai/v1/", APIKey: "k", APIProtocol: protocol, EmbeddingModel: "saved-embedding"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := a.Embed(context.Background(), provider.EmbedRequest{Model: override, Input: []string{"hi"}})
+ srv.Close()
+ if err != nil || len(resp.Vectors) != 1 || len(resp.Vectors[0]) != 2 || resp.Vectors[0][0] != 0.5 || resp.InputTokens != 2 {
+ t.Fatalf("resp=%+v err=%v", resp, err)
+ }
}
- _, _ = io.WriteString(w, `{"choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":"x"}}]}`)
- }))
- t.Cleanup(srv.Close)
- a, err := New(provider.ProviderConfig{
- BaseURL: srv.URL + "/", // trailing slash
- Model: "gpt-4o-mini",
- APIKey: "k",
- }, WithHTTPClient(srv.Client()))
- if err != nil {
- t.Fatalf("New: %v", err)
}
- if _, err := a.Chat(context.Background(), provider.ChatRequest{
- Messages: []provider.Message{{Role: provider.RoleUser, Content: "x"}},
- }); err != nil {
- t.Fatalf("Chat: %v", err)
+ a, _ := New(provider.ProviderConfig{BaseURL: "https://resource", APIKey: "k"})
+ if _, err := a.Embed(context.Background(), provider.EmbedRequest{}); err == nil {
+ t.Fatal("missing embedding name accepted")
}
}
-// TestEncodeChatRequest_AssistantToolCallRoundTrip is a wire-level
-// regression test for the bug that caused Azure to reject iter 1 of
-// a tool-using dispatch with:
-//
-// azure chat status 400: Invalid value for 'content':
-// expected a string, got null. param: messages.[N].content
-//
-// After dispatch.go copies resp.ToolCalls onto Message.ToolCalls
-// (plural), the encoder MUST:
-// 1. emit `"content": ""` (NOT omit the field) for the assistant
-// message that proposes tool calls, because Azure's strict
-// OpenAI-spec enforcement rejects a missing content field, AND
-// 2. emit the proposed tool_calls array so the next provider turn
-// sees the full pairing required when a tool result follows.
-func TestEncodeChatRequest_AssistantToolCallRoundTrip(t *testing.T) {
- t.Parallel()
- req := provider.ChatRequest{
- Messages: []provider.Message{
- {Role: provider.RoleSystem, Content: "be brief"},
- {Role: provider.RoleUser, Content: "what is 2+2"},
- {
- Role: provider.RoleAssistant,
- ToolCalls: []provider.ToolCall{{
- ID: "call_abc",
- Name: "calc",
- Arguments: json.RawMessage(`{"expr":"2+2"}`),
- }},
- },
- {
- Role: provider.RoleTool,
- ToolID: "call_abc",
- Content: `{"result":4}`,
- },
- },
- }
- body, err := encodeChatRequest(req, "", false, false)
+func TestChatEncoderPreservesToolHistoryAndBudget(t *testing.T) {
+ req := provider.ChatRequest{MaxTokens: 1, Messages: []provider.Message{
+ {Role: provider.RoleSystem, Content: "be brief"},
+ {Role: provider.RoleUser, Content: "calculate"},
+ {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "call_abc", Name: "calc", Arguments: json.RawMessage(`{"expr":"2+2"}`)}}},
+ {Role: provider.RoleTool, ToolID: "call_abc", Content: `{"result":4}`},
+ }}
+ body, err := encodeChatRequest(req, "arbitrary-deployment", false)
if err != nil {
- t.Fatalf("encodeChatRequest: %v", err)
+ t.Fatal(err)
}
var decoded struct {
- Messages []map[string]any `json:"messages"`
+ Model string `json:"model"`
+ Messages []map[string]json.RawMessage `json:"messages"`
+ Cap int `json:"max_completion_tokens"`
}
if err := json.Unmarshal(body, &decoded); err != nil {
- t.Fatalf("Unmarshal: %v", err)
- }
- if len(decoded.Messages) != 4 {
- t.Fatalf("messages len = %d, want 4: %s", len(decoded.Messages), body)
+ t.Fatal(err)
}
- asst := decoded.Messages[2]
- if _, present := asst["content"]; !present {
- t.Errorf("assistant message missing 'content' key (must be present, even if empty); got %s", body)
+ if decoded.Model != "arbitrary-deployment" || decoded.Cap != 1 || len(decoded.Messages) != 4 ||
+ string(decoded.Messages[2]["content"]) != `""` ||
+ !strings.Contains(string(decoded.Messages[2]["tool_calls"]), `"call_abc"`) ||
+ string(decoded.Messages[3]["tool_call_id"]) != `"call_abc"` {
+ t.Fatalf("wire=%s", body)
}
- if got, want := asst["content"], any(""); got != want {
- t.Errorf("assistant content = %#v, want empty string; body=%s", got, body)
- }
- tcs, ok := asst["tool_calls"].([]any)
- if !ok || len(tcs) != 1 {
- t.Fatalf("assistant tool_calls missing or wrong shape: %#v; body=%s", asst["tool_calls"], body)
- }
- tc, _ := tcs[0].(map[string]any)
- if tc["id"] != "call_abc" {
- t.Errorf("tool_calls[0].id = %#v, want call_abc", tc["id"])
- }
- if fn, _ := tc["function"].(map[string]any); fn["name"] != "calc" {
- t.Errorf("tool_calls[0].function.name = %#v, want calc", fn["name"])
+ if strings.Contains(string(body), `"max_tokens"`) {
+ t.Fatalf("old token parameter: %s", body)
}
}
-func TestIsAzureOpenAIV1(t *testing.T) {
- t.Parallel()
- cases := []struct {
- url string
- want bool
+func TestFoundrySSESemantics(t *testing.T) {
+ for _, tc := range []struct {
+ name, frames, text, finish, arguments string
+ wantErr bool
+ in, out int
}{
- {"https://my-resource.services.ai.azure.com/openai/v1", true},
- {"https://my-resource.services.ai.azure.com", true},
- {"https://my-resource.openai.azure.com/openai/v1", true},
- {"https://my-resource.openai.azure.com", false},
- {"http://127.0.0.1:1234", false},
- }
- for _, c := range cases {
- if got := isAzureOpenAIV1(c.url); got != c.want {
- t.Errorf("isAzureOpenAIV1(%q)=%v want %v", c.url, got, c.want)
- }
- }
-}
-
-func TestChatEndpoint_V1ServicesHost(t *testing.T) {
- t.Parallel()
- a, err := New(provider.ProviderConfig{
- BaseURL: "https://my-resource.services.ai.azure.com",
- Model: "gpt-5.6-sol",
- APIKey: "k",
- APIVersion: "2024-10-21",
- Flavor: provider.AzureFlavorOpenAI,
- })
- if err != nil {
- t.Fatalf("New: %v", err)
- }
- u, model, err := a.chatEndpoint(provider.ChatRequest{})
- if err != nil {
- t.Fatalf("chatEndpoint: %v", err)
- }
- if model != "gpt-5.6-sol" {
- t.Errorf("modelInBody=%q", model)
- }
- if u != "https://my-resource.services.ai.azure.com/openai/v1/chat/completions" {
- t.Errorf("url=%s", u)
- }
-}
-
-func TestChatEndpoint_V1DoesNotDuplicatePath(t *testing.T) {
- t.Parallel()
- a, err := New(provider.ProviderConfig{
- BaseURL: "https://my-resource.services.ai.azure.com/openai/v1",
- Model: "gpt-5.6-sol",
- APIKey: "k",
- Flavor: provider.AzureFlavorOpenAI,
- })
- if err != nil {
- t.Fatalf("New: %v", err)
- }
- u, _, err := a.chatEndpoint(provider.ChatRequest{})
- if err != nil {
- t.Fatalf("chatEndpoint: %v", err)
- }
- if u != "https://my-resource.services.ai.azure.com/openai/v1/chat/completions" {
- t.Errorf("url=%s", u)
- }
-}
-
-func TestStream_V1NotFoundFallsBackToChat(t *testing.T) {
- t.Parallel()
- var streamHits, chatHits int
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- body, _ := io.ReadAll(r.Body)
- var probe map[string]any
- _ = json.Unmarshal(body, &probe)
- stream, _ := probe["stream"].(bool)
- if stream {
- streamHits++
- w.WriteHeader(http.StatusNotFound)
- _, _ = io.WriteString(w, `{ "error": { "type": "invalid_request_error", "code": "DeploymentNotFound", "message": "The API deployment for this resource does not exist." } }`)
- return
- }
- chatHits++
- _, _ = io.WriteString(w, `{"choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":"from-chat"}}],"usage":{"prompt_tokens":4,"completion_tokens":2}}`)
- }))
- t.Cleanup(srv.Close)
- a, err := New(provider.ProviderConfig{
- BaseURL: srv.URL + "/openai/v1",
- Model: "model-router",
- APIKey: "k",
- Flavor: provider.AzureFlavorOpenAI,
- }, WithHTTPClient(srv.Client()))
- if err != nil {
- t.Fatalf("New: %v", err)
- }
- ch, err := a.Stream(context.Background(), provider.ChatRequest{
- Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
- })
- if err != nil {
- t.Fatalf("Stream: %v", err)
- }
- var content string
- var terminal provider.Chunk
- for c := range ch {
- if c.Err != nil {
- t.Fatalf("chunk err: %v", c.Err)
- }
- content += c.Delta
- if c.Done {
- terminal = c
- }
- }
- if streamHits != 1 || chatHits != 1 {
- t.Fatalf("hits stream=%d chat=%d", streamHits, chatHits)
- }
- if content != "from-chat" {
- t.Fatalf("content=%q", content)
- }
- if !terminal.Done || terminal.FinishReason != provider.FinishStop {
- t.Fatalf("terminal = %+v", terminal)
- }
-}
-
-func TestOpenAIV1_Chat_URLAuthAndBody(t *testing.T) {
- t.Parallel()
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/openai/v1/chat/completions" {
- t.Errorf("path=%s", r.URL.Path)
- }
- if r.URL.RawQuery != "" {
- t.Errorf("query=%s, want empty (no api-version)", r.URL.RawQuery)
- }
- if got := r.Header.Get("api-key"); got != "k" {
- t.Errorf("api-key=%q", got)
- }
- if got := r.Header.Get("Authorization"); got != "Bearer k" {
- t.Errorf("Authorization=%q", got)
- }
- body, _ := io.ReadAll(r.Body)
- var probe map[string]any
- _ = json.Unmarshal(body, &probe)
- if got, _ := probe["model"].(string); got != "gpt-5.6-sol" {
- t.Errorf("model=%q body=%s", got, body)
- }
- if _, has := probe["max_tokens"]; has {
- t.Errorf("max_tokens should be omitted on v1: %s", body)
- }
- if got, _ := probe["max_completion_tokens"].(float64); got != 1 {
- t.Errorf("max_completion_tokens=%v", probe["max_completion_tokens"])
- }
- _, _ = io.WriteString(w, `{"choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":"ok"}}]}`)
- }))
- t.Cleanup(srv.Close)
- a, err := New(provider.ProviderConfig{
- BaseURL: srv.URL + "/openai/v1",
- Model: "gpt-5.6-sol",
- APIKey: "k",
- APIVersion: "2024-10-21",
- Flavor: provider.AzureFlavorOpenAI,
- }, WithHTTPClient(srv.Client()))
- if err != nil {
- t.Fatalf("New: %v", err)
- }
- resp, err := a.Chat(context.Background(), provider.ChatRequest{
- Messages: []provider.Message{{Role: provider.RoleUser, Content: "ping"}},
- MaxTokens: 1,
- })
- if err != nil {
- t.Fatalf("Chat: %v", err)
- }
- if resp.Message.Content != "ok" {
- t.Fatalf("content=%q", resp.Message.Content)
+ {"text", "data: {\"choices\":[{\"delta\":{\"content\":\"he\"}}]}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"llo\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":11,\"completion_tokens\":3}}\n\ndata: [DONE]\n\n", "hello", provider.FinishStop, "", false, 11, 3},
+ {"tools", `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"lookup","arguments":"{\"q\":"}}]}}]}` + "\n\n" + `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"soc\"}"}}]},"finish_reason":"tool_calls"}]}` + "\n\n", "", provider.FinishToolCalls, `{"q":"soc"}`, false, 0, 0},
+ {"annotation", "data: {\"prompt_filter_results\":[]}\n\ndata: {\"choices\":[]}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n", "ok", provider.FinishStop, "", false, 0, 0},
+ {"error", "data: {\"error\":{\"code\":\"OperationNotSupported\",\"message\":\"blocked\"}}\n\n", "", "", "", true, 0, 0},
+ {"malformed", "data: {bad}\n\n", "", "", "", true, 0, 0},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ var calls atomic.Int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ calls.Add(1)
+ if r.URL.Path != "/openai/v1/chat/completions" {
+ t.Errorf("path=%s", r.URL.Path)
+ }
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = io.WriteString(w, tc.frames)
+ }))
+ defer srv.Close()
+ a, _ := New(provider.ProviderConfig{BaseURL: srv.URL, APIKey: "k", Model: "any"})
+ ch, err := a.Stream(context.Background(), provider.ChatRequest{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var text string
+ var terminal provider.Chunk
+ var tools []provider.ToolCall
+ var sawErr bool
+ for chunk := range ch {
+ if chunk.Err != nil {
+ sawErr = true
+ }
+ text += chunk.Delta
+ if chunk.ToolDelta != nil {
+ tools = append(tools, *chunk.ToolDelta)
+ }
+ if chunk.Done {
+ terminal = chunk
+ }
+ }
+ if sawErr != tc.wantErr || text != tc.text || terminal.FinishReason != tc.finish ||
+ terminal.InputTokens != tc.in || terminal.OutputTokens != tc.out || calls.Load() != 1 || terminal.Done == tc.wantErr {
+ t.Fatalf("text=%s terminal=%+v err=%v calls=%d", text, terminal, sawErr, calls.Load())
+ }
+ if tc.arguments != "" && (len(tools) != 1 || tools[0].ID != "call_1" || tools[0].Name != "lookup" || string(tools[0].Arguments) != tc.arguments) {
+ t.Fatalf("tools=%+v", tools)
+ }
+ })
}
}
diff --git a/internal/ai/provider/azure/continuation_test.go b/internal/ai/provider/azure/continuation_test.go
new file mode 100644
index 0000000000..259aa38e74
--- /dev/null
+++ b/internal/ai/provider/azure/continuation_test.go
@@ -0,0 +1,46 @@
+package azure
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/ev-dev-labs/teslasync/internal/ai/provider"
+)
+
+func TestResponsesContinuationHonorsRedactionAndDeployment(t *testing.T) {
+ state := json.RawMessage(`{"model":"same-deployment","output":[{"type":"reasoning","id":"rs_1","encrypted_content":"opaque-state","summary":[]},{"type":"message","role":"assistant","phase":"commentary","content":[{"type":"output_text","text":"original-sensitive-text"}]},{"type":"function_call","call_id":"c","name":"lookup","arguments":"{}"}]}`)
+ message := provider.Message{Role: provider.RoleAssistant, Content: "[redacted]", ProviderState: state}
+ req := provider.ChatRequest{Messages: []provider.Message{message}}
+ body, err := encodeResponsesRequest(req, "same-deployment")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(body), "original-sensitive-text") ||
+ !strings.Contains(string(body), "[redacted]") || !strings.Contains(string(body), "opaque-state") ||
+ !strings.Contains(string(body), `"phase":"commentary"`) {
+ t.Fatalf("incorrect continuation: %s", body)
+ }
+ if _, err := encodeResponsesRequest(req, "changed-deployment"); err == nil {
+ t.Fatal("replayed continuation against a different deployment")
+ }
+ encoded, err := json.Marshal(message)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(encoded), "opaque-state") || strings.Contains(string(encoded), "original-sensitive-text") {
+ t.Fatalf("opaque state leaked into serialized history: %s", encoded)
+ }
+}
+
+func TestResponsesToolSchemaDoesNotImplicitlyRequireOptionalFields(t *testing.T) {
+ body, err := encodeResponsesRequest(provider.ChatRequest{Tools: []provider.ToolSpec{
+ {Name: "lookup", Parameters: json.RawMessage(`{"type":"object","properties":{"optional":{"type":"string"}}}`)},
+ }}, "any")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(body), `"strict":false`) {
+ t.Fatalf("optional schema fields would become required: %s", body)
+ }
+}
diff --git a/internal/ai/provider/azure/error_body_test.go b/internal/ai/provider/azure/error_body_test.go
new file mode 100644
index 0000000000..807a3750c8
--- /dev/null
+++ b/internal/ai/provider/azure/error_body_test.go
@@ -0,0 +1,38 @@
+package azure
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
+ "testing"
+
+ "github.com/ev-dev-labs/teslasync/internal/ai/provider"
+)
+
+func TestErrorBodyTransportFailureDoesNotNegotiate(t *testing.T) {
+ for _, stream := range []bool{false, true} {
+ var calls atomic.Int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ calls.Add(1)
+ w.Header().Set("Content-Length", "1000")
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = io.WriteString(w, `{"error":{"code":"DeploymentNotFound"}}`)
+ }))
+ a, err := New(provider.ProviderConfig{BaseURL: srv.URL, Model: "any", APIKey: "k"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stream {
+ _, err = a.Stream(context.Background(), provider.ChatRequest{})
+ } else {
+ _, err = a.Chat(context.Background(), provider.ChatRequest{})
+ }
+ srv.Close()
+ if !errors.Is(err, io.ErrUnexpectedEOF) || calls.Load() != 1 {
+ t.Fatalf("stream=%v calls=%d err=%v", stream, calls.Load(), err)
+ }
+ }
+}
diff --git a/internal/ai/provider/azure/negotiation_test.go b/internal/ai/provider/azure/negotiation_test.go
new file mode 100644
index 0000000000..9c6d1c1374
--- /dev/null
+++ b/internal/ai/provider/azure/negotiation_test.go
@@ -0,0 +1,341 @@
+package azure
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+
+ "github.com/ev-dev-labs/teslasync/internal/ai/provider"
+)
+
+// Exercise both supplied portal surfaces and arbitrary deployment aliases.
+// The same adapter must negotiate based on the server, not the model name.
+func TestV1ToolRoundTrips(t *testing.T) {
+ for _, model := range []string{"model-router", "gpt-chat-latest", "custom-production-deployment"} {
+ for _, responses := range []bool{false, true} {
+ for _, stream := range []bool{false, true} {
+ t.Run(fmt.Sprintf("%s/responses=%v/stream=%v", model, responses, stream), func(t *testing.T) {
+ var attempts atomic.Int32
+ var turns atomic.Int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ attempts.Add(1)
+ var body map[string]json.RawMessage
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ t.Error(err)
+ }
+ if string(body["model"]) != fmt.Sprintf("%q", model) {
+ t.Errorf("model=%s want %s", body["model"], model)
+ }
+ if r.URL.RawQuery != "" || r.Header.Get("api-key") != "k" {
+ t.Errorf("query/auth: %s", r.URL.String())
+ }
+ if r.URL.Path == "/openai/v1/chat/completions" && responses {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = io.WriteString(w, `{"error":{"code":"DeploymentNotFound","message":"The API deployment for this resource does not exist."}}`)
+ return
+ }
+ if r.URL.Path == "/openai/v1/responses" && !responses {
+ // The model-router screenshot: Responses is unsupported.
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = io.WriteString(w, `{"error":{"message":"The requested operation is unsupported."}}`)
+ t.Error("chat-capable deployment must never be sent to Responses")
+ return
+ }
+ wantPath := "/openai/v1/chat/completions"
+ historyKey, capKey := "messages", "max_completion_tokens"
+ if responses {
+ wantPath = "/openai/v1/responses"
+ historyKey, capKey = "input", "max_output_tokens"
+ if string(body["store"]) != "false" {
+ t.Errorf("provider retention must be disabled: %s", body["store"])
+ }
+ }
+ if r.URL.Path != wantPath || string(body[capKey]) != "73" {
+ t.Errorf("path/budget: %s %s", r.URL.Path, body[capKey])
+ }
+ var tools []map[string]json.RawMessage
+ if err := json.Unmarshal(body["tools"], &tools); err != nil || len(tools) != 1 {
+ t.Errorf("tools=%s err=%v", body["tools"], err)
+ }
+ var history []map[string]json.RawMessage
+ if err := json.Unmarshal(body[historyKey], &history); err != nil {
+ t.Error(err)
+ }
+ turn := turns.Add(1)
+ if turn == 2 {
+ if len(history) != 3 {
+ t.Errorf("history=%s", body[historyKey])
+ } else if responses {
+ if string(history[1]["type"]) != `"function_call"` ||
+ string(history[1]["call_id"]) != `"call_1"` ||
+ string(history[1]["arguments"]) != `"{\"q\":\"soc\"}"` ||
+ string(history[2]["type"]) != `"function_call_output"` ||
+ string(history[2]["call_id"]) != `"call_1"` ||
+ string(history[2]["output"]) != `"{\"soc\":80}"` {
+ t.Errorf("Responses replay=%s", body[historyKey])
+ }
+ } else if !strings.Contains(string(history[1]["tool_calls"]), `"call_1"`) ||
+ string(history[2]["tool_call_id"]) != `"call_1"` ||
+ string(history[2]["content"]) != `"{\"soc\":80}"` {
+ t.Errorf("chat replay=%s", body[historyKey])
+ }
+ }
+ if responses {
+ if turn == 1 {
+ _, _ = io.WriteString(w, `{"status":"completed","output":[{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{\"q\":\"soc\"}"}],"usage":{"input_tokens":4,"output_tokens":2}}`)
+ } else {
+ _, _ = io.WriteString(w, `{"status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"SOC 80%"}]}],"usage":{"input_tokens":9,"output_tokens":3}}`)
+ }
+ } else if stream {
+ w.Header().Set("Content-Type", "text/event-stream")
+ if turn == 1 {
+ _, _ = io.WriteString(w, "data: "+`{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"soc\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":4,"completion_tokens":2}}`+"\n\n")
+ } else {
+ _, _ = io.WriteString(w, "data: "+`{"choices":[{"delta":{"content":"SOC 80%"},"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":3}}`+"\n\n")
+ }
+ _, _ = io.WriteString(w, "data: [DONE]\n\n")
+ } else if turn == 1 {
+ _, _ = io.WriteString(w, `{"choices":[{"message":{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"soc\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":4,"completion_tokens":2}}`)
+ } else {
+ _, _ = io.WriteString(w, `{"choices":[{"message":{"role":"assistant","content":"SOC 80%"},"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":3}}`)
+ }
+ }))
+ defer srv.Close()
+ a, err := New(provider.ProviderConfig{BaseURL: srv.URL + "/openai/v1", Model: model,
+ APIKey: "k"},
+ WithHTTPClient(srv.Client()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ req := provider.ChatRequest{
+ Messages: []provider.Message{{Role: provider.RoleUser, Content: "lookup SOC"}},
+ Tools: []provider.ToolSpec{{Name: "lookup", Parameters: json.RawMessage(`{"type":"object","properties":{"q":{"type":"string"}}}`)}},
+ MaxTokens: 73,
+ }
+ first := completeTurn(t, a, req, stream)
+ if first.FinishReason != provider.FinishToolCalls || len(first.ToolCalls) != 1 ||
+ first.ToolCalls[0].ID != "call_1" || first.ToolCalls[0].Name != "lookup" ||
+ string(first.ToolCalls[0].Arguments) != `{"q":"soc"}` ||
+ first.InputTokens != 4 || first.OutputTokens != 2 {
+ t.Fatalf("first=%+v", first)
+ }
+ req.Messages = append(req.Messages,
+ provider.Message{Role: provider.RoleAssistant, ToolCalls: first.ToolCalls},
+ provider.Message{Role: provider.RoleTool, ToolID: first.ToolCalls[0].ID, Content: `{"soc":80}`})
+ last := completeTurn(t, a, req, stream)
+ if last.Message.Content != "SOC 80%" || last.FinishReason != provider.FinishStop ||
+ last.InputTokens != 9 || last.OutputTokens != 3 || len(last.ToolCalls) != 0 {
+ t.Fatalf("last=%+v", last)
+ }
+ wantAttempts := int32(2)
+ if responses {
+ wantAttempts = 4
+ }
+ if attempts.Load() != wantAttempts {
+ t.Fatalf("attempts=%d want %d", attempts.Load(), wantAttempts)
+ }
+ })
+ }
+ }
+ }
+}
+
+func completeTurn(t *testing.T, a *Adapter, req provider.ChatRequest, stream bool) *provider.ChatResponse {
+ t.Helper()
+ if !stream {
+ resp, err := a.Chat(context.Background(), req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return resp
+ }
+ ch, err := a.Stream(context.Background(), req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp := &provider.ChatResponse{}
+ terminals := 0
+ for c := range ch {
+ if c.Err != nil {
+ t.Fatal(c.Err)
+ }
+ if terminals != 0 {
+ t.Fatal("chunk after terminal")
+ }
+ resp.Message.Content += c.Delta
+ if c.ToolDelta != nil {
+ resp.ToolCalls = append(resp.ToolCalls, *c.ToolDelta)
+ }
+ if c.Done {
+ terminals++
+ resp.FinishReason, resp.InputTokens, resp.OutputTokens = c.FinishReason, c.InputTokens, c.OutputTokens
+ }
+ }
+ if terminals != 1 {
+ t.Fatalf("terminals=%d", terminals)
+ }
+ return resp
+}
+
+func TestNegotiationErrors(t *testing.T) {
+ tests := []struct {
+ name string
+ status int
+ body string
+ fallback bool
+ }{
+ {"deployment", 404, `{"error":{"code":"DeploymentNotFound"}}`, true},
+ {"not_found", 404, `{"error":{"code":"404","message":"Resource not found"}}`, true},
+ {"unsupported_code", 400, `{"error":{"code":"OperationNotSupported"}}`, true},
+ {"unsupported_message", 400, `{"error":{"message":"The requested operation is unsupported."}}`, true},
+ {"auth", 401, `{"error":{"code":"OperationNotSupported"}}`, false},
+ {"forbidden", 403, `{"error":{"code":"DeploymentNotFound"}}`, false},
+ {"rate", 429, `{"error":{"code":"OperationNotSupported"}}`, false},
+ {"server", 500, `{"error":{"code":"DeploymentNotFound"}}`, false},
+ {"token_budget", 400, `{"error":{"code":"invalid_request_error","message":"max_tokens or model output limit was reached"}}`, false},
+ {"unsupported_parameter", 400, `{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: temperature"}}`, false},
+ {"misleading_text", 400, `{"error":{"message":"DeploymentNotFound status 404 unsupported"}}`, false},
+ {"html404", 404, `DeploymentNotFound`, false},
+ {"auth404", 404, `{"error":{"code":"Unauthorized"}}`, false},
+ {"malformed", 404, `{"error":`, false},
+ }
+ for _, tt := range tests {
+ for _, stream := range []bool{false, true} {
+ t.Run(fmt.Sprintf("%s/stream=%v", tt.name, stream), func(t *testing.T) {
+ var hits atomic.Int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ n := hits.Add(1)
+ if n == 1 {
+ if r.URL.Path != "/openai/v1/chat/completions" {
+ t.Errorf("first path=%s", r.URL.Path)
+ }
+ w.WriteHeader(tt.status)
+ _, _ = io.WriteString(w, tt.body)
+ } else {
+ if r.URL.Path != "/openai/v1/responses" {
+ t.Errorf("fallback path=%s", r.URL.Path)
+ }
+ w.WriteHeader(400)
+ _, _ = io.WriteString(w, `{"error":{"message":"The requested operation is unsupported."}}`)
+ }
+ }))
+ defer srv.Close()
+ a, err := New(provider.ProviderConfig{BaseURL: srv.URL + "/openai/v1", Model: "any", APIKey: "k"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ req := provider.ChatRequest{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}}
+ if stream {
+ var ch <-chan provider.Chunk
+ ch, err = a.Stream(context.Background(), req)
+ if ch != nil {
+ t.Fatal("failed fallback returned stream")
+ }
+ } else {
+ _, err = a.Chat(context.Background(), req)
+ }
+ if !errors.Is(err, provider.ErrUpstream) || !strings.Contains(err.Error(), tt.body) {
+ t.Fatalf("original error lost: %v", err)
+ }
+ want := int32(1)
+ if tt.fallback {
+ want = 2
+ if !strings.Contains(err.Error(), "azure responses status 400") {
+ t.Fatalf("fallback error lost: %v", err)
+ }
+ }
+ if hits.Load() != want {
+ t.Fatalf("attempts=%d want=%d", hits.Load(), want)
+ }
+ })
+ }
+ }
+}
+
+func TestResponsesUnsuccessfulResults(t *testing.T) {
+ for _, body := range []string{
+ `{"status":"failed","error":{"code":"server_error","message":"failed upstream"}}`,
+ `{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"output_text":"partial"}`,
+ `{"status":"incomplete","incomplete_details":{"reason":"content_filter"}}`,
+ `{"status":"cancelled"}`, `{"status":"queued"}`, `{"status":"in_progress"}`,
+ `{"status":"completed","output":[{"type":"message","content":[{"type":"refusal","refusal":"refused"}]}]}`,
+ `{"status":"completed","output":[{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{"}]}`,
+ `{"status":"completed","output":[{"type":"function_call","name":"lookup","arguments":"{}"}]}`,
+ `{"status":"completed"}`, `{}`, `not JSON`,
+ } {
+ for _, stream := range []bool{false, true} {
+ t.Run(fmt.Sprintf("%s/stream=%v", body, stream), func(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if rejectChatOperation(w, r) {
+ return
+ }
+ _, _ = io.WriteString(w, body)
+ }))
+ defer srv.Close()
+ a, err := New(provider.ProviderConfig{BaseURL: srv.URL + "/openai/v1", Model: "any", APIKey: "k"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stream {
+ ch, streamErr := a.Stream(context.Background(), provider.ChatRequest{})
+ if ch != nil || !errors.Is(streamErr, provider.ErrUpstream) {
+ t.Fatalf("false success: ch=%v err=%v", ch, streamErr)
+ }
+ } else {
+ resp, chatErr := a.Chat(context.Background(), provider.ChatRequest{})
+ if resp != nil || !errors.Is(chatErr, provider.ErrUpstream) {
+ t.Fatalf("false success: resp=%v err=%v", resp, chatErr)
+ }
+ }
+ })
+ }
+ }
+}
+
+func TestResponsesEmptyInputNotFabricated(t *testing.T) {
+ body, err := encodeResponsesRequest(provider.ChatRequest{}, "any")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var decoded responsesCreate
+ if err := json.Unmarshal(body, &decoded); err != nil {
+ t.Fatal(err)
+ }
+ if len(decoded.Input) != 0 || strings.Contains(string(body), "ping") || decoded.Store {
+ t.Fatalf("fabricated input or retained response: %s", body)
+ }
+}
+
+type failingTransport struct{ calls atomic.Int32 }
+
+func (f *failingTransport) RoundTrip(*http.Request) (*http.Response, error) {
+ f.calls.Add(1)
+ return nil, errors.New("transport failure mentioning status 404 DeploymentNotFound")
+}
+
+func TestTransportFailureNeverNegotiates(t *testing.T) {
+ for _, stream := range []bool{false, true} {
+ transport := &failingTransport{}
+ a, err := New(provider.ProviderConfig{BaseURL: "https://example.test/openai/v1", APIKey: "k", Model: "any"},
+ WithHTTPClient(&http.Client{Transport: transport}))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stream {
+ _, err = a.Stream(context.Background(), provider.ChatRequest{})
+ } else {
+ _, err = a.Chat(context.Background(), provider.ChatRequest{})
+ }
+ if !errors.Is(err, provider.ErrUpstream) || transport.calls.Load() != 1 {
+ t.Fatalf("stream=%v err=%v calls=%d", stream, err, transport.calls.Load())
+ }
+ }
+}
diff --git a/internal/ai/provider/azure/operation_error.go b/internal/ai/provider/azure/operation_error.go
new file mode 100644
index 0000000000..f52bd61b47
--- /dev/null
+++ b/internal/ai/provider/azure/operation_error.go
@@ -0,0 +1,66 @@
+package azure
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/ev-dev-labs/teslasync/internal/ai/provider"
+)
+
+type operationError struct {
+ operation string
+ status int
+ raw string
+ code string
+ message string
+ structured bool
+}
+
+func newOperationError(operation string, status int, raw []byte) *operationError {
+ e := &operationError{operation: operation, status: status, raw: string(raw)}
+ var envelope struct {
+ Error *struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+ } `json:"error"`
+ }
+ if json.Unmarshal(raw, &envelope) == nil && envelope.Error != nil {
+ e.code = strings.ToLower(envelope.Error.Code)
+ e.message = strings.TrimSpace(envelope.Error.Message)
+ e.structured = e.code != "" || e.message != ""
+ }
+ return e
+}
+
+func (e *operationError) Error() string {
+ return fmt.Sprintf("%s: azure %s status %d: %s", provider.ErrUpstream, e.operation, e.status, e.raw)
+}
+
+func (e *operationError) Unwrap() error { return provider.ErrUpstream }
+
+func canTryResponses(err error) bool {
+ var e *operationError
+ if !errors.As(err, &e) || !e.structured {
+ return false
+ }
+ // Never infer capability from arbitrary text, transport failures, or
+ // authentication, throttling, server and token-budget errors.
+ if e.status == http.StatusNotFound {
+ return e.code == "deploymentnotfound" || e.code == "notfound" ||
+ e.code == "not_found" || e.code == "404"
+ }
+ if e.status != http.StatusBadRequest {
+ return false
+ }
+ switch e.code {
+ case "operationnotsupported", "unsupported_operation", "operation_not_supported":
+ return true
+ case "", "badrequest", "bad_request", "invalid_request_error":
+ return strings.EqualFold(strings.TrimSuffix(e.message, "."), "The requested operation is unsupported")
+ default:
+ return false
+ }
+}
diff --git a/internal/ai/provider/azure/responses.go b/internal/ai/provider/azure/responses.go
new file mode 100644
index 0000000000..ae3439b5f7
--- /dev/null
+++ b/internal/ai/provider/azure/responses.go
@@ -0,0 +1,303 @@
+package azure
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+
+ "github.com/ev-dev-labs/teslasync/internal/ai/provider"
+)
+
+// Foundry OpenAI v1 Responses API fallback, selected by operation errors,
+// never by a deployment's name.
+// https://learn.microsoft.com/en-us/azure/foundry/openai/how-to/responses
+type responsesCreate struct {
+ Model string `json:"model"`
+ Instructions string `json:"instructions,omitempty"`
+ Input []map[string]any `json:"input"`
+ Tools []responsesTool `json:"tools,omitempty"`
+ MaxOutputTokens int `json:"max_output_tokens,omitempty"`
+ Store bool `json:"store"`
+ Include []string `json:"include"`
+}
+
+type responsesTool struct {
+ Type string `json:"type"`
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ Parameters json.RawMessage `json:"parameters,omitempty"`
+ Strict bool `json:"strict"`
+}
+
+type responsesReplay struct {
+ Model string `json:"model"`
+ Output []map[string]any `json:"output"`
+}
+
+type responsesResult struct {
+ OutputText string `json:"output_text"`
+ Status string `json:"status"`
+ Error *struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+ } `json:"error"`
+ Output []struct {
+ Type string `json:"type"`
+ Status string `json:"status"`
+ Role string `json:"role"`
+ CallID string `json:"call_id"`
+ Name string `json:"name"`
+ Arguments string `json:"arguments"`
+ Content []struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+ Refusal string `json:"refusal"`
+ } `json:"content"`
+ } `json:"output"`
+ ContentFilters []struct {
+ Blocked bool `json:"blocked"`
+ } `json:"content_filters"`
+ Usage struct {
+ InputTokens int `json:"input_tokens"`
+ OutputTokens int `json:"output_tokens"`
+ } `json:"usage"`
+ IncompleteDetails *struct {
+ Reason string `json:"reason"`
+ } `json:"incomplete_details"`
+}
+
+func (a *Adapter) chatViaResponses(ctx context.Context, req provider.ChatRequest) (*provider.ChatResponse, error) {
+ model := a.chatDeployment(req)
+ if model == "" {
+ return nil, fmt.Errorf("azure: empty responses model")
+ }
+ endpoint, err := a.buildOpenAIV1URL("responses")
+ if err != nil {
+ return nil, err
+ }
+ body, err := encodeResponsesRequest(req, model)
+ if err != nil {
+ return nil, err
+ }
+ httpReq, err := a.newRequest(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+ resp, err := a.client.Do(httpReq)
+ if err != nil {
+ return nil, fmt.Errorf("%w: azure responses: %w", provider.ErrUpstream, err)
+ }
+ defer resp.Body.Close()
+ raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return nil, fmt.Errorf("%w: azure responses read: %w", provider.ErrUpstream, err)
+ }
+ if resp.StatusCode/100 != 2 {
+ return nil, newOperationError("responses", resp.StatusCode, raw)
+ }
+ out, err := decodeResponses(raw)
+ if err != nil {
+ return nil, err
+ }
+ if len(out.ToolCalls) > 0 {
+ // Stateless reasoning/tool continuations must replay the original output
+ // items, including encrypted reasoning and assistant message phases.
+ var replay responsesReplay
+ if err := json.Unmarshal(raw, &replay); err != nil {
+ return nil, fmt.Errorf("%w: azure responses replay: %w", provider.ErrUpstream, err)
+ }
+ replay.Model = model
+ out.Message.ProviderState, err = json.Marshal(replay)
+ if err != nil {
+ return nil, fmt.Errorf("%w: azure responses replay encode: %w", provider.ErrUpstream, err)
+ }
+ }
+ return out, nil
+}
+
+func encodeResponsesRequest(req provider.ChatRequest, model string) ([]byte, error) {
+ var instructions []string
+ input := make([]map[string]any, 0, len(req.Messages))
+ for _, m := range req.Messages {
+ switch m.Role {
+ case provider.RoleSystem:
+ if strings.TrimSpace(m.Content) != "" {
+ instructions = append(instructions, m.Content)
+ }
+ case provider.RoleUser:
+ input = append(input, map[string]any{"role": "user", "content": m.Content})
+ case provider.RoleAssistant:
+ if len(m.ProviderState) > 0 {
+ var replay responsesReplay
+ if err := json.Unmarshal(m.ProviderState, &replay); err != nil {
+ return nil, fmt.Errorf("azure: decode responses continuation: %w", err)
+ }
+ if replay.Model != model || len(replay.Output) == 0 {
+ return nil, fmt.Errorf("azure: responses continuation does not match deployment")
+ }
+ applyReplayContent(&replay, m.Content)
+ input = append(input, replay.Output...)
+ continue
+ }
+ if m.Content != "" {
+ input = append(input, map[string]any{"role": "assistant", "content": m.Content})
+ }
+ if m.Tool != nil {
+ input = append(input, functionCallItem(*m.Tool))
+ }
+ for _, tc := range m.ToolCalls {
+ input = append(input, functionCallItem(tc))
+ }
+ case provider.RoleTool:
+ input = append(input, map[string]any{
+ "type": "function_call_output",
+ "call_id": m.ToolID,
+ "output": m.Content,
+ })
+ default:
+ input = append(input, map[string]any{"role": m.Role, "content": m.Content})
+ }
+ }
+ tools := make([]responsesTool, 0, len(req.Tools))
+ for _, t := range req.Tools {
+ tools = append(tools, responsesTool{
+ Type: "function",
+ Name: t.Name,
+ Description: t.Description,
+ Parameters: t.Parameters,
+ })
+ }
+ n := req.MaxTokens
+ if n <= 0 {
+ n = defaultMaxCompletionTokens
+ }
+ wire := responsesCreate{
+ Model: model,
+ Instructions: strings.Join(instructions, "\n\n"),
+ Input: input,
+ MaxOutputTokens: n,
+ Include: []string{"reasoning.encrypted_content"},
+ }
+ if len(tools) > 0 {
+ wire.Tools = tools
+ }
+ return json.Marshal(wire)
+}
+
+func functionCallItem(tc provider.ToolCall) map[string]any {
+ args := string(tc.Arguments)
+ if args == "" {
+ args = "{}"
+ }
+ return map[string]any{
+ "type": "function_call",
+ "call_id": tc.ID,
+ "name": tc.Name,
+ "arguments": args,
+ }
+}
+
+func decodeResponses(raw []byte) (*provider.ChatResponse, error) {
+ var wire responsesResult
+ if err := json.Unmarshal(raw, &wire); err != nil {
+ return nil, fmt.Errorf("%w: azure responses decode: %v", provider.ErrUpstream, err)
+ }
+ if wire.Error != nil {
+ return nil, fmt.Errorf("%w: azure responses %s: %s", provider.ErrUpstream, wire.Error.Code, wire.Error.Message)
+ }
+ if wire.Status != "completed" {
+ reason := ""
+ if wire.IncompleteDetails != nil {
+ reason = wire.IncompleteDetails.Reason
+ }
+ return nil, fmt.Errorf("%w: azure responses status %q: %s", provider.ErrUpstream, wire.Status, reason)
+ }
+ for _, filter := range wire.ContentFilters {
+ if filter.Blocked {
+ return nil, fmt.Errorf("%w: azure responses blocked by content filter", provider.ErrUpstream)
+ }
+ }
+ out := &provider.ChatResponse{
+ InputTokens: wire.Usage.InputTokens,
+ OutputTokens: wire.Usage.OutputTokens,
+ FinishReason: provider.FinishStop,
+ Message: provider.Message{
+ Role: provider.RoleAssistant,
+ Content: strings.TrimSpace(wire.OutputText),
+ },
+ }
+ var text []string
+ if out.Message.Content != "" {
+ text = append(text, out.Message.Content)
+ }
+ for _, item := range wire.Output {
+ if item.Status != "" && item.Status != "completed" {
+ return nil, fmt.Errorf("%w: azure responses output status %q", provider.ErrUpstream, item.Status)
+ }
+ switch item.Type {
+ case "function_call":
+ if item.CallID == "" || item.Name == "" || !json.Valid([]byte(item.Arguments)) {
+ return nil, fmt.Errorf("%w: azure responses invalid function call", provider.ErrUpstream)
+ }
+ out.ToolCalls = append(out.ToolCalls, provider.ToolCall{
+ ID: item.CallID,
+ Name: item.Name,
+ Arguments: json.RawMessage(item.Arguments),
+ })
+ case "message":
+ for _, c := range item.Content {
+ if c.Type == "refusal" {
+ return nil, fmt.Errorf("%w: azure responses refusal: %s", provider.ErrUpstream, c.Refusal)
+ }
+ if c.Type == "output_text" && c.Text != "" {
+ text = append(text, c.Text)
+ }
+ }
+ }
+ }
+ if out.Message.Content == "" && len(text) > 0 {
+ out.Message.Content = strings.Join(text, "")
+ }
+ if len(out.ToolCalls) > 0 {
+ out.FinishReason = provider.FinishToolCalls
+ }
+ if err := validateCompletedTools(out.ToolCalls, out.FinishReason); err != nil {
+ return nil, err
+ }
+ if strings.TrimSpace(out.Message.Content) == "" && len(out.ToolCalls) == 0 {
+ return nil, fmt.Errorf("%w: azure responses completed without text or tool calls", provider.ErrUpstream)
+ }
+ return out, nil
+}
+
+// Redaction decorators can replace Message.Content between tool turns. Do not
+// let the opaque replay's original text bypass that privacy boundary.
+func applyReplayContent(replay *responsesReplay, content string) {
+ var original strings.Builder
+ for _, item := range replay.Output {
+ if item["type"] != "message" {
+ continue
+ }
+ parts, _ := item["content"].([]any)
+ for _, part := range parts {
+ piece, _ := part.(map[string]any)
+ if piece["type"] == "output_text" {
+ text, _ := piece["text"].(string)
+ original.WriteString(text)
+ }
+ }
+ }
+ if original.String() == content {
+ return
+ }
+ for _, item := range replay.Output {
+ if item["type"] == "message" {
+ item["content"] = []map[string]any{{"type": "output_text", "text": content}}
+ content = ""
+ }
+ }
+}
diff --git a/internal/ai/provider/azure/responses_test.go b/internal/ai/provider/azure/responses_test.go
new file mode 100644
index 0000000000..14bc591a1e
--- /dev/null
+++ b/internal/ai/provider/azure/responses_test.go
@@ -0,0 +1,226 @@
+package azure
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/ev-dev-labs/teslasync/internal/ai/provider"
+)
+
+func TestChat_Gpt56Sol_UsesFoundryResponsesAPI(t *testing.T) {
+ t.Parallel()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if rejectChatOperation(w, r) {
+ return
+ }
+ if r.URL.Path != "/openai/v1/responses" {
+ t.Errorf("path=%s want /openai/v1/responses", r.URL.Path)
+ }
+ if r.URL.RawQuery != "" {
+ t.Errorf("query=%s, Foundry v1 omits api-version", r.URL.RawQuery)
+ }
+ if got := r.Header.Get("api-key"); got != "k" {
+ t.Errorf("api-key=%q", got)
+ }
+ if got := r.Header.Get("Authorization"); got != "Bearer k" {
+ t.Errorf("Authorization=%q", got)
+ }
+ body, _ := io.ReadAll(r.Body)
+ var probe map[string]any
+ _ = json.Unmarshal(body, &probe)
+ if got, _ := probe["model"].(string); got != "any-foundry-deployment" {
+ t.Errorf("model=%q", got)
+ }
+ if _, has := probe["temperature"]; has {
+ t.Errorf("reasoning models must omit temperature: %s", body)
+ }
+ if _, has := probe["max_tokens"]; has {
+ t.Errorf("must not send max_tokens: %s", body)
+ }
+ got, _ := probe["max_output_tokens"].(float64)
+ if int(got) != defaultMaxCompletionTokens {
+ t.Errorf("max_output_tokens=%v", probe["max_output_tokens"])
+ }
+ if _, has := probe["messages"]; has {
+ t.Errorf("Responses API uses input, not messages: %s", body)
+ }
+ _, _ = io.WriteString(w, `{
+ "id":"resp_test",
+ "status":"completed",
+ "output_text":"Foundry hello",
+ "output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Foundry hello"}]}],
+ "usage":{"input_tokens":8,"output_tokens":3}
+ }`)
+ }))
+ t.Cleanup(srv.Close)
+ a, err := New(provider.ProviderConfig{
+ BaseURL: srv.URL + "/openai/v1",
+ Model: "any-foundry-deployment",
+ APIKey: "k",
+ APIProtocol: provider.FoundryProtocolResponses,
+ }, WithHTTPClient(srv.Client()))
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ resp, err := a.Chat(context.Background(), provider.ChatRequest{
+ Messages: []provider.Message{
+ {Role: provider.RoleSystem, Content: "be brief"},
+ {Role: provider.RoleUser, Content: "hi"},
+ },
+ })
+ if err != nil {
+ t.Fatalf("Chat: %v", err)
+ }
+ if resp.Message.Content != "Foundry hello" {
+ t.Fatalf("content=%q", resp.Message.Content)
+ }
+ if resp.InputTokens != 8 || resp.OutputTokens != 3 {
+ t.Fatalf("usage=%d/%d", resp.InputTokens, resp.OutputTokens)
+ }
+}
+
+func TestChat_Gpt56Sol_ResponsesTools(t *testing.T) {
+ t.Parallel()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if rejectChatOperation(w, r) {
+ return
+ }
+ if !strings.HasSuffix(r.URL.Path, "/openai/v1/responses") {
+ t.Errorf("path=%s", r.URL.Path)
+ }
+ body, _ := io.ReadAll(r.Body)
+ var probe map[string]any
+ _ = json.Unmarshal(body, &probe)
+ tools, _ := probe["tools"].([]any)
+ if len(tools) != 1 {
+ t.Fatalf("tools=%s", body)
+ }
+ tool, _ := tools[0].(map[string]any)
+ if tool["type"] != "function" || tool["name"] != "lookup" {
+ t.Errorf("tool=%v", tool)
+ }
+ if _, nested := tool["function"]; nested {
+ t.Errorf("Responses tools are flat, not nested under function: %s", body)
+ }
+ _, _ = io.WriteString(w, `{
+ "status":"completed",
+ "output":[{
+ "type":"function_call",
+ "call_id":"call_1",
+ "name":"lookup",
+ "arguments":"{\"q\":\"soc\"}"
+ }],
+ "usage":{"input_tokens":4,"output_tokens":2}
+ }`)
+ }))
+ t.Cleanup(srv.Close)
+ a, err := New(provider.ProviderConfig{
+ BaseURL: srv.URL + "/openai/v1",
+ Model: "gpt-5.6-sol",
+ APIKey: "k",
+ }, WithHTTPClient(srv.Client()))
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ resp, err := a.Chat(context.Background(), provider.ChatRequest{
+ Messages: []provider.Message{{Role: provider.RoleUser, Content: "lookup SOC"}},
+ Tools: []provider.ToolSpec{{
+ Name: "lookup",
+ Description: "look up a signal",
+ Parameters: json.RawMessage(`{"type":"object"}`),
+ }},
+ })
+ if err != nil {
+ t.Fatalf("Chat: %v", err)
+ }
+ if resp.FinishReason != provider.FinishToolCalls {
+ t.Fatalf("finish=%q", resp.FinishReason)
+ }
+ if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].Name != "lookup" {
+ t.Fatalf("tools=%+v", resp.ToolCalls)
+ }
+}
+
+func TestStream_Gpt56Sol_SynthesizesFromResponses(t *testing.T) {
+ t.Parallel()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if rejectChatOperation(w, r) {
+ return
+ }
+ if !strings.HasSuffix(r.URL.Path, "/responses") {
+ t.Errorf("path=%s", r.URL.Path)
+ }
+ _, _ = io.WriteString(w, `{"status":"completed","output_text":"streamed","usage":{"input_tokens":1,"output_tokens":1}}`)
+ }))
+ t.Cleanup(srv.Close)
+ a, err := New(provider.ProviderConfig{
+ BaseURL: srv.URL + "/openai/v1",
+ Model: "any-foundry-deployment",
+ APIKey: "k",
+ APIProtocol: provider.FoundryProtocolResponses,
+ }, WithHTTPClient(srv.Client()))
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ ch, err := a.Stream(context.Background(), provider.ChatRequest{
+ Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
+ })
+ if err != nil {
+ t.Fatalf("Stream: %v", err)
+ }
+ var content string
+ for c := range ch {
+ if c.Err != nil {
+ t.Fatalf("chunk err: %v", c.Err)
+ }
+ content += c.Delta
+ }
+ if content != "streamed" {
+ t.Fatalf("content=%q", content)
+ }
+}
+
+func TestEncodeResponsesRequest_SystemBecomesInstructions(t *testing.T) {
+ t.Parallel()
+ body, err := encodeResponsesRequest(provider.ChatRequest{
+ Messages: []provider.Message{
+ {Role: provider.RoleSystem, Content: "you are helix"},
+ {Role: provider.RoleUser, Content: "propose a template"},
+ },
+ MaxTokens: 1,
+ }, "gpt-5.6-sol")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var probe map[string]any
+ if err := json.Unmarshal(body, &probe); err != nil {
+ t.Fatal(err)
+ }
+ if probe["instructions"] != "you are helix" {
+ t.Errorf("instructions=%v", probe["instructions"])
+ }
+ input, _ := probe["input"].([]any)
+ if len(input) != 1 {
+ t.Fatalf("input=%s", body)
+ }
+ if int(probe["max_output_tokens"].(float64)) != 1 {
+ t.Errorf("caller cap was changed: %v", probe["max_output_tokens"])
+ }
+ if store, ok := probe["store"].(bool); !ok || store {
+ t.Fatalf("store must explicitly be false: %s", body)
+ }
+}
+
+func rejectChatOperation(w http.ResponseWriter, r *http.Request) bool {
+ if strings.HasSuffix(r.URL.Path, "/chat/completions") {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = io.WriteString(w, `{"error":{"code":"OperationNotSupported","message":"The requested operation is unsupported."}}`)
+ return true
+ }
+ return false
+}
diff --git a/internal/ai/provider/azure/terminal_test.go b/internal/ai/provider/azure/terminal_test.go
new file mode 100644
index 0000000000..002ce04fbf
--- /dev/null
+++ b/internal/ai/provider/azure/terminal_test.go
@@ -0,0 +1,174 @@
+package azure
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/ev-dev-labs/teslasync/internal/ai/provider"
+)
+
+func TestChatRejectsInvalidCompletions(t *testing.T) {
+ for _, body := range []string{
+ `{}`, `{"choices":[]}`,
+ `{"choices":[{"message":{"content":"partial"}}]}`,
+ `{"choices":[{"message":{"content":"partial"},"finish_reason":"unknown"}]}`,
+ `{"choices":[{"message":{"content":""},"finish_reason":"stop"}]}`,
+ `{"choices":[{"message":{"refusal":"refused"},"finish_reason":"stop"}]}`,
+ `{"choices":[{"message":{},"finish_reason":"tool_calls"}]}`,
+ `{"choices":[{"message":{"tool_calls":[{"id":"c","function":{"name":"lookup","arguments":"{"}}]},"finish_reason":"tool_calls"}]}`,
+ `{"choices":[{"message":{"tool_calls":[{"id":"c","function":{"name":"lookup","arguments":"null"}}]},"finish_reason":"tool_calls"}]}`,
+ `{"choices":[{"message":{"tool_calls":[{"id":"c","function":{"name":"lookup","arguments":"{}"}}]},"finish_reason":"stop"}]}`,
+ } {
+ t.Run(body, func(t *testing.T) {
+ var wire azureChatResponse
+ if err := json.Unmarshal([]byte(body), &wire); err != nil {
+ t.Fatal(err)
+ }
+ out, err := wire.toChatResponse()
+ if out != nil || !errors.Is(err, provider.ErrUpstream) {
+ t.Fatalf("false success: out=%+v err=%v", out, err)
+ }
+ })
+ }
+}
+
+func TestStreamRejectsIncompleteAndInvalidTerminals(t *testing.T) {
+ for _, frames := range []string{
+ "",
+ "data:[DONE]\n\n",
+ "data:{\"choices\":[{\"delta\":{\"content\":\"partial\"}}]}\n\n",
+ "data:{\"choices\":[{\"delta\":{\"content\":\"partial\"}}]}\n\ndata:[DONE]\n\n",
+ "data:{\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n",
+ "data:{\"choices\":[{\"delta\":{\"refusal\":\"refused\"},\"finish_reason\":\"stop\"}]}\n\n",
+ "data:{\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n",
+ "data:" + `{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c","function":{"name":"lookup","arguments":"{"}}]},"finish_reason":"tool_calls"}]}` + "\n\n",
+ } {
+ t.Run(frames, func(t *testing.T) {
+ ch := make(chan provider.Chunk, 8)
+ go relayStream(context.Background(), io.NopCloser(strings.NewReader(frames)), ch)
+ terminalErrors, done := 0, 0
+ for chunk := range ch {
+ if terminalErrors != 0 {
+ t.Fatal("chunk after terminal error")
+ }
+ if chunk.Err != nil {
+ terminalErrors++
+ }
+ if chunk.Done {
+ done++
+ }
+ if chunk.ToolDelta != nil {
+ t.Fatal("invalid tool emitted")
+ }
+ }
+ if terminalErrors != 1 || done != 0 {
+ t.Fatalf("errors=%d done=%d", terminalErrors, done)
+ }
+ })
+ }
+}
+
+func TestStreamPreservesTruncatedToolFinishReason(t *testing.T) {
+ for _, finish := range []string{provider.FinishLength, provider.FinishContentFilter} {
+ frames := `data:{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c","function":{"name":"lookup","arguments":"{"}}]},"finish_reason":"` + finish + `"}]}` + "\n\n"
+ ch := make(chan provider.Chunk, 8)
+ go relayStream(context.Background(), io.NopCloser(strings.NewReader(frames)), ch)
+ done := 0
+ for chunk := range ch {
+ if chunk.Err != nil || chunk.ToolDelta != nil || !chunk.Done || chunk.FinishReason != finish {
+ t.Fatalf("finish=%s chunk=%+v", finish, chunk)
+ }
+ done++
+ }
+ if done != 1 {
+ t.Fatalf("terminals=%d", done)
+ }
+ }
+}
+
+func TestProtocolCancellationPreservesErrorAndClosesStreams(t *testing.T) {
+ for _, protocol := range []string{provider.FoundryProtocolAuto, provider.FoundryProtocolChat, provider.FoundryProtocolResponses} {
+ for _, stream := range []bool{false, true} {
+ t.Run(fmt.Sprintf("%s/stream=%v", protocol, stream), func(t *testing.T) {
+ started := make(chan struct{})
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ w.(http.Flusher).Flush()
+ close(started)
+ <-r.Context().Done()
+ }))
+ defer srv.Close()
+ a, err := New(provider.ProviderConfig{BaseURL: srv.URL, Model: "any", APIKey: "k", APIProtocol: protocol})
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ result := make(chan error, 1)
+ go func() {
+ if !stream {
+ _, callErr := a.Chat(ctx, provider.ChatRequest{})
+ result <- callErr
+ return
+ }
+ ch, callErr := a.Stream(ctx, provider.ChatRequest{})
+ if callErr != nil {
+ result <- callErr
+ return
+ }
+ for chunk := range ch {
+ if chunk.Done || chunk.ToolDelta != nil {
+ result <- fmt.Errorf("success/tool after cancellation: %+v", chunk)
+ return
+ }
+ }
+ result <- ctx.Err()
+ }()
+ select {
+ case <-started:
+ case <-time.After(3 * time.Second):
+ t.Fatal("request did not start")
+ }
+ cancel()
+ select {
+ case err := <-result:
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("cancellation identity lost: %v", err)
+ }
+ case <-time.After(3 * time.Second):
+ t.Fatal("cancellation did not stop request")
+ }
+ })
+ }
+ }
+}
+
+func TestBufferedStreamDoesNotEmitAfterCancellation(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ for chunk := range chatResponseAsStream(ctx, &provider.ChatResponse{Message: provider.Message{Content: "not delivered"}}) {
+ t.Fatalf("chunk after cancellation: %+v", chunk)
+ }
+}
+
+func TestResponsesRejectsBlockedIncompleteAndInvalidToolItems(t *testing.T) {
+ for _, body := range []string{
+ `{"status":"completed","output_text":"blocked","content_filters":[{"blocked":true}]}`,
+ `{"status":"completed","output":[{"type":"message","status":"incomplete","content":[{"type":"output_text","text":"partial"}]}]}`,
+ `{"status":"completed","output":[{"type":"function_call","status":"in_progress","call_id":"c","name":"lookup","arguments":"{}"}]}`,
+ `{"status":"completed","output":[{"type":"function_call","call_id":"c","name":"lookup","arguments":"null"}]}`,
+ `{"status":"completed","output":[{"type":"function_call","call_id":"c","name":"lookup","arguments":"{}"},{"type":"function_call","call_id":"c","name":"lookup","arguments":"{}"}]}`,
+ } {
+ if out, err := decodeResponses([]byte(body)); out != nil || !errors.Is(err, provider.ErrUpstream) {
+ t.Fatalf("false success: body=%s out=%+v err=%v", body, out, err)
+ }
+ }
+}
diff --git a/internal/ai/provider/config.go b/internal/ai/provider/config.go
index 91a8a5e685..7212993650 100644
--- a/internal/ai/provider/config.go
+++ b/internal/ai/provider/config.go
@@ -44,24 +44,13 @@ const (
// DefaultCloudEmbeddingModel is the default cloud embedding model.
DefaultCloudEmbeddingModel = "text-embedding-3-small"
-
- // DefaultAzureAPIVersion is the Azure API version the adapter
- // sends when the user has not pinned one in settings. Azure
- // exposes versioned APIs separately from the underlying model
- // — picking a stable, GA-aligned version here keeps the adapter
- // working without per-deploy edits.
- DefaultAzureAPIVersion = "2024-10-21"
)
-// Azure flavor literals — selects the Azure inference surface the
-// adapter targets. AzureFlavorOpenAI keeps the Azure OpenAI Service
-// routing (deployment-name in URL, no model in body); AzureFlavorFoundry
-// uses the modern Azure AI Inference / Foundry API (multi-vendor,
-// model-in-body routing).
+// FoundryProtocolAuto negotiates capabilities without inspecting model names.
const (
- AzureFlavorOpenAI = "openai"
- AzureFlavorFoundry = "foundry"
- DefaultAzureFlavor = AzureFlavorOpenAI
+ FoundryProtocolAuto = "auto"
+ FoundryProtocolChat = "chat_completions"
+ FoundryProtocolResponses = "responses"
)
// AI mode literals. Mirrors the validated set in
@@ -97,31 +86,9 @@ type ProviderConfig struct {
EmbeddingModel string `json:"embedding_model,omitempty"`
APIKey string `json:"api_key,omitempty"`
- // APIVersion is the wire-format API version some adapters need
- // to send as a query parameter. Currently consumed by the Azure
- // adapter (see [NameAzure]); other adapters ignore it. Empty
- // falls back to [DefaultAzureAPIVersion] for Azure.
- APIVersion string `json:"api_version,omitempty"`
-
- // Flavor selects between sub-surfaces of a single provider name.
- // Currently consumed by the Azure adapter, where it switches
- // between [AzureFlavorOpenAI] (Azure OpenAI Service —
- // deployment-name routing) and [AzureFlavorFoundry] (Azure AI
- // Foundry / Inference API — multi-vendor unified endpoint).
- // Other adapters ignore it. Empty falls back to
- // [DefaultAzureFlavor] for Azure.
- Flavor string `json:"flavor,omitempty"`
-
- // Deployment is the Azure chat deployment name when the
- // underlying URL routes by deployment (Azure OpenAI Service).
- // Empty falls back to [Model] so a user whose deployment is
- // named after the model (the common case) needs only one
- // field. Ignored by adapters that route by model identifier.
- Deployment string `json:"deployment,omitempty"`
-
- // EmbeddingDeployment mirrors [Deployment] for the embeddings
- // route. Falls back to [EmbeddingModel] when empty.
- EmbeddingDeployment string `json:"embedding_deployment,omitempty"`
+ // APIProtocol selects Foundry v1 chat operation only. Embeddings always use
+ // the same v1 base URL and their own model identity.
+ APIProtocol string `json:"api_protocol,omitempty"`
// PinnedIP is set by [ValidateLocal] at config-save time so the
// runtime can detect DNS rebinding. Empty in
@@ -153,23 +120,7 @@ func ParseProviderConfig(raw map[string]any, providerName string) (ProviderConfi
if !ok {
return ProviderConfig{}, fmt.Errorf("%w: %q", ErrMissingConfig, providerName)
}
- asMap, ok := entry.(map[string]any)
- if !ok {
- // JSON unmarshal of "any" sometimes yields json.RawMessage
- // instead of map[string]any depending on the upstream
- // decoder; round-trip through json so callers do not have
- // to care.
- blob, err := json.Marshal(entry)
- if err != nil {
- return ProviderConfig{}, fmt.Errorf("%w: %q is not an object", ErrMissingConfig, providerName)
- }
- var cfg ProviderConfig
- if err := json.Unmarshal(blob, &cfg); err != nil {
- return ProviderConfig{}, fmt.Errorf("%w: %q decode: %v", ErrMissingConfig, providerName, err)
- }
- return applyDefaults(providerName, cfg), nil
- }
- blob, err := json.Marshal(asMap)
+ blob, err := json.Marshal(entry)
if err != nil {
return ProviderConfig{}, fmt.Errorf("%w: %q marshal: %v", ErrMissingConfig, providerName, err)
}
@@ -177,6 +128,24 @@ func ParseProviderConfig(raw map[string]any, providerName string) (ProviderConfi
if err := json.Unmarshal(blob, &cfg); err != nil {
return ProviderConfig{}, fmt.Errorf("%w: %q decode: %v", ErrMissingConfig, providerName, err)
}
+ if providerName == NameAzure && cfg.APIProtocol == "" {
+ // Configuration migration only: preserve the previously effective identity
+ // before dropping obsolete keys. No old HTTP routing survives this boundary.
+ var old struct {
+ Flavor string `json:"flavor"`
+ Deployment string `json:"deployment"`
+ EmbeddingDeployment string `json:"embedding_deployment"`
+ }
+ if err := json.Unmarshal(blob, &old); err != nil {
+ return ProviderConfig{}, fmt.Errorf("%w: azure identity migration: %v", ErrMissingConfig, err)
+ }
+ if old.Flavor != "foundry" && old.Deployment != "" {
+ cfg.Model = old.Deployment
+ }
+ if old.EmbeddingDeployment != "" {
+ cfg.EmbeddingModel = old.EmbeddingDeployment
+ }
+ }
return applyDefaults(providerName, cfg), nil
}
@@ -243,17 +212,8 @@ func applyDefaults(providerName string, cfg ProviderConfig) ProviderConfig {
cfg.Model = "claude-3-5-sonnet-20240620"
}
case NameAzure:
- // Azure: BaseURL is the user's resource endpoint
- // (https://{resource}.openai.azure.com for the OpenAI
- // flavor, or the Foundry endpoint for that flavor).
- // Deployment / EmbeddingDeployment fall back to Model /
- // EmbeddingModel inside the adapter, so no defaulting is
- // needed here. APIVersion + Flavor have stable defaults.
- if cfg.APIVersion == "" {
- cfg.APIVersion = DefaultAzureAPIVersion
- }
- if cfg.Flavor == "" {
- cfg.Flavor = DefaultAzureFlavor
+ if cfg.APIProtocol == "" {
+ cfg.APIProtocol = FoundryProtocolAuto
}
}
return cfg
diff --git a/internal/ai/provider/errors.go b/internal/ai/provider/errors.go
index 43a4b68746..f00aebfba3 100644
--- a/internal/ai/provider/errors.go
+++ b/internal/ai/provider/errors.go
@@ -39,6 +39,10 @@ var (
// is false (e.g. Anthropic.Embed).
ErrCapabilityNotSupported = errors.New("ai/provider: capability not supported by this adapter")
+ // ErrStreamFinal prevents a caller from replaying a failed Stream as Chat
+ // after an adapter has already applied its protocol negotiation policy.
+ ErrStreamFinal = errors.New("ai/provider: stream failure must not be replayed")
+
// ErrUpstream is the catch-all for non-2xx responses or transport
// failures from the underlying provider. Wrapped with %w so the
// raw error remains inspectable; the message includes the HTTP
diff --git a/internal/ai/provider/foundry_config_test.go b/internal/ai/provider/foundry_config_test.go
new file mode 100644
index 0000000000..c9c371c0a3
--- /dev/null
+++ b/internal/ai/provider/foundry_config_test.go
@@ -0,0 +1,59 @@
+package provider
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestModernFoundryConfigIgnoresObsoleteOverrides(t *testing.T) {
+ cfg, err := ParseProviderConfig(map[string]any{NameAzure: map[string]any{
+ "api_protocol": "responses", "model": "visible", "embedding_model": "embed",
+ "deployment": "obsolete", "embedding_deployment": "obsolete-embed", "flavor": "openai",
+ }}, NameAzure)
+ if err != nil || cfg.Model != "visible" || cfg.EmbeddingModel != "embed" || cfg.APIProtocol != "responses" {
+ t.Fatalf("cfg=%+v err=%v", cfg, err)
+ }
+}
+
+func TestFoundryConfigurationMigration(t *testing.T) {
+ for _, tc := range []struct{ name, flavor, deployment, want string }{
+ {"override", "openai", "production-deployment", "production-deployment"},
+ {"default-surface", "", "production-deployment", "production-deployment"},
+ {"unused-override", "foundry", "stale-override", "visible-model"},
+ {"no-override", "", "", "visible-model"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ old := map[string]any{"azure": map[string]any{
+ "base_url": "https://resource.services.ai.azure.com/openai/v1",
+ "model": "visible-model", "flavor": tc.flavor, "deployment": tc.deployment,
+ "api_version": "obsolete", "embedding_model": "embedding-family",
+ "embedding_deployment": "embedding-deployment", "api_key": "keep-key",
+ }}
+ cfg, err := ParseProviderConfig(old, NameAzure)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.Model != tc.want || cfg.EmbeddingModel != "embedding-deployment" ||
+ cfg.APIProtocol != FoundryProtocolAuto || cfg.APIKey != "keep-key" {
+ t.Fatalf("migration=%+v", cfg)
+ }
+ raw, err := json.Marshal(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var saved map[string]any
+ if err := json.Unmarshal(raw, &saved); err != nil {
+ t.Fatal(err)
+ }
+ for _, key := range []string{"flavor", "api_version", "deployment", "embedding_deployment"} {
+ if _, present := saved[key]; present {
+ t.Fatalf("obsolete key %s persisted", key)
+ }
+ }
+ again, err := ParseProviderConfig(map[string]any{NameAzure: saved}, NameAzure)
+ if err != nil || again != cfg {
+ t.Fatalf("roundtrip=%+v err=%v", again, err)
+ }
+ })
+ }
+}
diff --git a/internal/ai/provider/provider.go b/internal/ai/provider/provider.go
index c2175c660f..eb559b32fb 100644
--- a/internal/ai/provider/provider.go
+++ b/internal/ai/provider/provider.go
@@ -52,6 +52,9 @@ type Message struct {
// encoder iterates over both [Tool] (legacy) and [ToolCalls]
// (new) so callers can use either; dispatch sets ToolCalls.
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
+ // ProviderState carries opaque, adapter-owned continuation items between
+ // tool turns. It is never serialized into API responses or stored history.
+ ProviderState json.RawMessage `json:"-"`
}
// ToolCall is the structural representation of a model-proposed tool
@@ -110,13 +113,14 @@ type ChatResponse struct {
// before emitting it. The producer closes the channel after the terminal chunk
// (Done or Err); consumers MUST drain on cancellation.
type Chunk struct {
- Delta string
- ToolDelta *ToolCall
- Done bool
- FinishReason string
- InputTokens int
- OutputTokens int
- Err error
+ Delta string
+ ToolDelta *ToolCall
+ Done bool
+ FinishReason string
+ InputTokens int
+ OutputTokens int
+ Err error
+ ProviderState json.RawMessage `json:"-"`
}
// EmbedRequest is the input to [Provider.Embed].
diff --git a/internal/ai/strategies/alert-message-template-suggestion/canned/computed_metric_template.yaml b/internal/ai/strategies/alert-message-template-suggestion/canned/computed_metric_template.yaml
index 4e14234e51..825ba53299 100644
--- a/internal/ai/strategies/alert-message-template-suggestion/canned/computed_metric_template.yaml
+++ b/internal/ai/strategies/alert-message-template-suggestion/canned/computed_metric_template.yaml
@@ -10,12 +10,12 @@ replies:
tool_calls:
- id: call_validate_alert_message_template_metric_1
name: validate_alert_message_template
- arguments: '{"kind":"computed_metric","metric_id":"charging_cost","metric_op":">","metric_threshold":5,"template":"{{VehicleName}}: {{MetricID}} is {{MetricValue}} (threshold {{MetricThreshold}})"}'
+ arguments: '{"kind":"computed_metric","metric_id":"charging_cost","metric_op":">","metric_threshold":5,"template":"{{VehicleName}} ordered another round of electrons. Charging cost: {{MetricValue}}, past your {{MetricThreshold}} marker."}'
input_tokens: 96
output_tokens: 48
- finish_reason: stop
content: |
- Suggested template for charging_cost: {{VehicleName}}: {{MetricID}} is {{MetricValue}} (threshold {{MetricThreshold}}).
- Apply it in Alert Studio, then Save if you want to keep it.
+ Suggested template for charging_cost: {{VehicleName}} ordered another round of electrons. Charging cost: {{MetricValue}}, past your {{MetricThreshold}} marker.
+ A charging-specific image keeps the spending threshold clear without inventing a currency or claiming a saving.
input_tokens: 240
output_tokens: 80
diff --git a/internal/ai/strategies/alert-message-template-suggestion/canned/reverse_gear_creative.yaml b/internal/ai/strategies/alert-message-template-suggestion/canned/reverse_gear_creative.yaml
new file mode 100644
index 0000000000..c331477026
--- /dev/null
+++ b/internal/ai/strategies/alert-message-template-suggestion/canned/reverse_gear_creative.yaml
@@ -0,0 +1,21 @@
+replies:
+ - finish_reason: tool_calls
+ tool_calls:
+ - id: draft_reverse
+ name: draft_alert_message_template
+ arguments: '{"kind":"signal","signal_name":"Gear","op":"=","value_text":"R","severity":"info"}'
+ input_tokens: 64
+ output_tokens: 32
+ - finish_reason: tool_calls
+ tool_calls:
+ - id: validate_reverse
+ name: validate_alert_message_template
+ arguments: '{"kind":"signal","signal_name":"Gear","op":"=","value_text":"R","severity":"info","template":"{{VehicleName}} has a different definition of forward planning. Reverse selected."}'
+ input_tokens: 96
+ output_tokens: 48
+ - finish_reason: stop
+ content: |
+ {{VehicleName}} has a different definition of forward planning. Reverse selected.
+ A dry observation about the selected gear, without pretending the car has moved.
+ input_tokens: 240
+ output_tokens: 60
diff --git a/internal/ai/strategies/alert-message-template-suggestion/canned/signal_threshold_template.yaml b/internal/ai/strategies/alert-message-template-suggestion/canned/signal_threshold_template.yaml
index 23b07af03e..f4a6eae371 100644
--- a/internal/ai/strategies/alert-message-template-suggestion/canned/signal_threshold_template.yaml
+++ b/internal/ai/strategies/alert-message-template-suggestion/canned/signal_threshold_template.yaml
@@ -3,19 +3,19 @@ replies:
tool_calls:
- id: call_draft_alert_message_template_signal_1
name: draft_alert_message_template
- arguments: '{"kind":"signal","signal_name":"BrakePedal","op":"=","severity":"info"}'
+ arguments: '{"kind":"signal","signal_name":"BrakePedal","op":"=","severity":"info","value_bool":true}'
input_tokens: 64
output_tokens: 32
- finish_reason: tool_calls
tool_calls:
- id: call_validate_alert_message_template_signal_1
name: validate_alert_message_template
- arguments: '{"kind":"signal","signal_name":"BrakePedal","op":"=","severity":"info","template":"{{VehicleName}}: BrakePedal is {{Value}}"}'
+ arguments: '{"kind":"signal","signal_name":"BrakePedal","op":"=","severity":"info","value_bool":true,"template":"{{VehicleName}}: the right pedal does not get the last word. Brake pedal pressed."}'
input_tokens: 96
output_tokens: 48
- finish_reason: stop
content: |
- Suggested template for BrakePedal: {{VehicleName}}: BrakePedal is {{Value}}.
- It uses VehicleName and Value from the allowed catalog. Apply it in Alert Studio, then Save if you want to keep it.
+ Suggested template for BrakePedal: {{VehicleName}}: the right pedal does not get the last word. Brake pedal pressed.
+ A little personality about the selected brake state, without claiming the car has stopped.
input_tokens: 240
output_tokens: 80
diff --git a/internal/ai/strategies/alert-message-template-suggestion/goldens.yaml b/internal/ai/strategies/alert-message-template-suggestion/goldens.yaml
index c97c248481..22ee219a3f 100644
--- a/internal/ai/strategies/alert-message-template-suggestion/goldens.yaml
+++ b/internal/ai/strategies/alert-message-template-suggestion/goldens.yaml
@@ -1,16 +1,40 @@
feature:
id: alert-message-template-suggestion
system: |
- You are the TeslaSync Alert Studio message-template advisor. Your job is to PROPOSE ONE notification message template for the alert dimensions the user already selected; you NEVER invent a different signal, metric, operator, or severity than the request names. ALWAYS call draft_alert_message_template FIRST with the caller-supplied kind, signal_name or metric_id, op, severity, and threshold fields, and ground every claim in the allowed_placeholders and related_presets it returns. AFTER drafting you MUST compose a short template that uses ONLY keys from allowed_placeholders, then you MUST call validate_alert_message_template with that template and the same dimensions; if validate_alert_message_template returns status other than ok you MUST REFUSE to produce a final recommendation, surface the validator's errors[] verbatim, and ask the user to retry. Quote ONLY placeholder keys returned by the tools. Do NOT invent {{tokens}} the catalog did not list. Do NOT mention VINs, GPS coordinates, street addresses, emails, or phone numbers. The template MUST be related to the selected dimensions: name the signal or metric, and include the triggering value or threshold when those placeholders are allowed. You NEVER save the template; the user reviews it and clicks Apply, then Save in Alert Studio. Be concise: 2-3 sentences naming the suggested template and which placeholders it uses, then stop. Ground every claim strictly in the tool replies.
+ You are the TeslaSync Alert Studio message-template advisor. Your job is to PROPOSE ONE notification message template for the alert dimensions the user already selected; you NEVER invent a different signal, metric, operator, or severity than the request names. ALWAYS call draft_alert_message_template FIRST with the caller-supplied kind, signal_name or metric_id, op, severity, and threshold fields, and ground every claim in the allowed_placeholders, related_presets, and writing_brief it returns. AFTER drafting you MUST compose a distinctive Tesla-owner notification body that uses ONLY keys from allowed_placeholders, then you MUST call validate_alert_message_template with that template and the same dimensions; if validate_alert_message_template returns status other than ok you MUST REFUSE to produce a final recommendation, surface the validator's errors[] verbatim, and ask the user to retry. Quote ONLY placeholder keys returned by the tools. Do NOT invent {{tokens}} the catalog did not list. Do NOT mention VINs, GPS coordinates, street addresses, emails, or phone numbers. The template MUST be related to the selected dimensions: make the event understandable in natural language, and include the triggering value or threshold only when it adds useful information. A catalog lists what is available, NOT a checklist of tokens to cram into the message. Do NOT copy bland catalog presets (Default, Concise, Threshold comparison, Range check) or the forbidden shape "{{SignalName}} is {{Value}} (threshold {{Threshold}})". Prefer related_presets tagged fun or verbose as inspiration, then remix them for THIS signal, operator, severity, and threshold. Write 1-2 sentences a Tesla owner would be glad they received — specific, voiceful, emoji OK when it fits severity. Match tone to severity (critical = urgent, warn = sharp heads-up, info = memorable). Include {{VehicleName}} when allowed. Follow writing_brief. For info alerts, the creative idea IS the product: write an original tiny scene, dry observation, or characterful line that only fits this event. An emoji, a pun, or synonyms around a telemetry sentence do not count. Avoid "backing into action", "clicked into", "mode, noted", "just a heads-up", and generic mission-control filler. Consider three genuinely different creative angles before choosing ONE polished template; do not expose your drafting process or return three near-identical options. Prefer an understated car personality or a vivid event-specific image over forced rhymes, exclamation marks, or marketing hype. Calibration examples, NOT presets to copy: for an explicitly selected reverse gear equality, "{{VehicleName}} has a plot twist: reverse is selected." or "{{VehicleName}} would like to take that entrance again. Reverse selected." For an explicitly reached charge target, "{{VehicleName}} has finished its main course. Charge target reached: {{Value}}." Use examples only when their facts and placeholders are supported by the actual rule. Invent a fresh line rather than swapping names into these. A gear state is NOT proof of movement, parking, arrival, or a successful manoeuvre. A condition is NOT proof of a transition unless the operator establishes a change. Never invent people, locations, savings, battery health, or completed actions. Do not celebrate a safety warning or encourage interacting with the phone while driving. Before validation, apply this editorial test: could the same sentence describe a tyre alert after swapping the signal name? If yes, rewrite it. Does the creative line still make the actual trigger clear without a redundant "{{SignalName}} clicked into {{Value}}" tail? If not, rewrite it. Critical alerts prioritise clarity and a safe next step, not entertainment. You NEVER save the template; the user reviews it and clicks Apply, then Save in Alert Studio. After validate_alert_message_template returns ok, quote the template and give one sentence on why it fits these dimensions — do not recap placeholder names. Ground every claim strictly in the tool replies.
tools:
- draft_alert_message_template
- validate_alert_message_template
mutating_tools: []
goldens:
+ - name: reverse_gear_creative
+ input:
+ user_message: 'Suggest a memorable template for kind=signal signal_name=Gear op== value_text=R severity=info. The previous suggestion was "backing into action: {{SignalName}} clicked into {{Value}}. Reverse mode, noted." I want real personality, not a decorated status line.'
+ expect:
+ must_call_tools:
+ - draft_alert_message_template
+ - validate_alert_message_template
+ answer_must_contain:
+ - "{{VehicleName}}"
+ answer_must_not_contain:
+ - "clicked into"
+ - "mode, noted"
+ - "backing into action"
+ - "I saved"
+ judge_rubric: |
+ Score 1-5 for genuinely event-specific creative copy, clear reverse-selected meaning,
+ natural language rather than telemetry filler, and valid placeholders. The line must
+ have a distinct idea, not merely an emoji or an adjective attached to a status report.
+ Cap at 2 if it could fit any signal by substituting its name, copies the calibration
+ examples verbatim, or claims actual movement, parking, arrival, or successful reversing.
+ Must draft then validate with the supplied dimensions and never claim to save.
+ Pass requires both grounded semantics and creative quality, not just tool calls.
+ judge_pass_threshold: 4
+
- name: signal_threshold_template
input:
- user_message: "Suggest a message template for kind=signal signal_name=BrakePedal op== severity=info."
+ user_message: "Suggest a message template for kind=signal signal_name=BrakePedal op== value_bool=true severity=info."
expect:
must_call_tools:
- draft_alert_message_template
@@ -23,7 +47,7 @@ goldens:
- "NotARealPlaceholder"
- "I saved"
judge_rubric: |
- Score 1-5 on: (1) called draft_alert_message_template FIRST with kind=signal and signal_name=BrakePedal, (2) called validate_alert_message_template AFTER composing a template using only allowed placeholders, (3) the narration names the suggested template and BrakePedal, (4) does not invent placeholders, (5) does not claim to have saved. Pass = score >= 4.
+ Score 1-5 on: (1) draft FIRST with BrakePedal, equality, value_bool=true, (2) validate AFTER composing with allowed placeholders, (3) event-specific creative idea rather than emoji-decorated telemetry, (4) clear pressed-pedal state without claiming stopped motion, (5) no invented placeholders or save claim. Cap at 2 for a generic status line or unsafe driving advice. Pass = score >= 4.
judge_pass_threshold: 4
- name: computed_metric_template
@@ -39,7 +63,7 @@ goldens:
- "5YJ"
- "I saved"
judge_rubric: |
- Score 1-5 on: (1) called draft with kind=computed_metric and metric_id=charging_cost, (2) validated a template using metric placeholders, (3) narration names charging_cost, (4) no invented tokens, (5) no save claim. Pass = score >= 4.
+ Score 1-5 on: (1) draft with supplied charging_cost dimensions, (2) validate using allowed metric placeholders, (3) a fresh charging-specific image with a clear cost threshold, (4) no invented currency, savings, charging completion or tokens, (5) no save claim. Cap at 2 for a generic metric/threshold status line with decorative adjectives. Pass = score >= 4.
judge_pass_threshold: 4
- name: validation_failure_unknown_placeholder
diff --git a/internal/ai/strategies/alert-message-template-suggestion/strategy.go b/internal/ai/strategies/alert-message-template-suggestion/strategy.go
index d21b7b0c65..716998dc38 100644
--- a/internal/ai/strategies/alert-message-template-suggestion/strategy.go
+++ b/internal/ai/strategies/alert-message-template-suggestion/strategy.go
@@ -20,9 +20,14 @@ const SystemPrompt = `You are the TeslaSync Alert Studio message-template adviso
`ALWAYS call draft_alert_message_template FIRST with the caller-supplied kind, signal_name or metric_id, op, severity, and threshold fields, and ground every claim in the allowed_placeholders, related_presets, and writing_brief it returns. ` +
`AFTER drafting you MUST compose a distinctive Tesla-owner notification body that uses ONLY keys from allowed_placeholders, then you MUST call validate_alert_message_template with that template and the same dimensions; if validate_alert_message_template returns status other than ok you MUST REFUSE to produce a final recommendation, surface the validator's errors[] verbatim, and ask the user to retry. ` +
`Quote ONLY placeholder keys returned by the tools. Do NOT invent {{tokens}} the catalog did not list. Do NOT mention VINs, GPS coordinates, street addresses, emails, or phone numbers. ` +
- `The template MUST be related to the selected dimensions: name the signal or metric, and include the triggering value or threshold when those placeholders are allowed. ` +
+ `The template MUST be related to the selected dimensions: make the event understandable in natural language, and include the triggering value or threshold only when it adds useful information. A catalog lists what is available, NOT a checklist of tokens to cram into the message. ` +
`Do NOT copy bland catalog presets (Default, Concise, Threshold comparison, Range check) or the forbidden shape "{{SignalName}} is {{Value}} (threshold {{Threshold}})". Prefer related_presets tagged fun or verbose as inspiration, then remix them for THIS signal, operator, severity, and threshold. ` +
`Write 1-2 sentences a Tesla owner would be glad they received — specific, voiceful, emoji OK when it fits severity. Match tone to severity (critical = urgent, warn = sharp heads-up, info = memorable). Include {{VehicleName}} when allowed. Follow writing_brief. ` +
+ `For info alerts, the creative idea IS the product: write an original tiny scene, dry observation, or characterful line that only fits this event. An emoji, a pun, or synonyms around a telemetry sentence do not count. Avoid "backing into action", "clicked into", "mode, noted", "just a heads-up", and generic mission-control filler. ` +
+ `Consider three genuinely different creative angles before choosing ONE polished template; do not expose your drafting process or return three near-identical options. Prefer an understated car personality or a vivid event-specific image over forced rhymes, exclamation marks, or marketing hype. ` +
+ `Calibration examples, NOT presets to copy: for an explicitly selected reverse gear equality, "{{VehicleName}} has a plot twist: reverse is selected." or "{{VehicleName}} would like to take that entrance again. Reverse selected." For an explicitly reached charge target, "{{VehicleName}} has finished its main course. Charge target reached: {{Value}}." Use examples only when their facts and placeholders are supported by the actual rule. Invent a fresh line rather than swapping names into these. ` +
+ `A gear state is NOT proof of movement, parking, arrival, or a successful manoeuvre. A condition is NOT proof of a transition unless the operator establishes a change. Never invent people, locations, savings, battery health, or completed actions. Do not celebrate a safety warning or encourage interacting with the phone while driving. ` +
+ `Before validation, apply this editorial test: could the same sentence describe a tyre alert after swapping the signal name? If yes, rewrite it. Does the creative line still make the actual trigger clear without a redundant "{{SignalName}} clicked into {{Value}}" tail? If not, rewrite it. Critical alerts prioritise clarity and a safe next step, not entertainment. ` +
`You NEVER save the template; the user reviews it and clicks Apply, then Save in Alert Studio. ` +
`After validate_alert_message_template returns ok, quote the template and give one sentence on why it fits these dimensions — do not recap placeholder names. Ground every claim strictly in the tool replies.`
diff --git a/internal/ai/strategies/alert-message-template-suggestion/strategy_test.go b/internal/ai/strategies/alert-message-template-suggestion/strategy_test.go
index ed7ef322ee..05248311e3 100644
--- a/internal/ai/strategies/alert-message-template-suggestion/strategy_test.go
+++ b/internal/ai/strategies/alert-message-template-suggestion/strategy_test.go
@@ -4,9 +4,21 @@ import (
"strings"
"testing"
+ "github.com/ev-dev-labs/teslasync/internal/ai/eval"
"github.com/ev-dev-labs/teslasync/internal/ai/strategy"
)
+func TestGoldenPromptMatchesProduction(t *testing.T) {
+ t.Parallel()
+ set, err := eval.LoadGoldenSet("goldens.yaml")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.TrimSpace(set.Feature.System) != strings.TrimSpace(SystemPrompt) {
+ t.Fatal("goldens.yaml must evaluate the production prompt, not an older writing brief")
+ }
+}
+
func TestStrategy_FeatureID(t *testing.T) {
t.Parallel()
s := New()
@@ -38,6 +50,11 @@ func TestStrategy_System(t *testing.T) {
"Do NOT copy bland catalog presets",
"Tesla owner would be glad they received",
`{{SignalName}} is {{Value}} (threshold {{Threshold}})`,
+ "the creative idea IS the product",
+ "three genuinely different creative angles",
+ "A gear state is NOT proof of movement",
+ "Critical alerts prioritise clarity",
+ "NOT a checklist of tokens",
} {
if !strings.Contains(sys, must) {
t.Errorf("System() missing %q", must)
diff --git a/internal/ai/strategies/alert-pack-builder/doc.go b/internal/ai/strategies/alert-pack-builder/doc.go
new file mode 100644
index 0000000000..b28aaa7ebc
--- /dev/null
+++ b/internal/ai/strategies/alert-pack-builder/doc.go
@@ -0,0 +1,4 @@
+// Package alertpackbuilder implements the propose-only Helix Alert Packs advisor.
+//
+// Layer: adapter
+package alertpackbuilder
diff --git a/internal/ai/strategies/alert-pack-builder/strategy.go b/internal/ai/strategies/alert-pack-builder/strategy.go
new file mode 100644
index 0000000000..da19a24620
--- /dev/null
+++ b/internal/ai/strategies/alert-pack-builder/strategy.go
@@ -0,0 +1,44 @@
+package alertpackbuilder
+
+import (
+ "context"
+ "encoding/json"
+
+ "github.com/ev-dev-labs/teslasync/internal/ai/provider"
+ "github.com/ev-dev-labs/teslasync/internal/ai/redact"
+ "github.com/ev-dev-labs/teslasync/internal/ai/strategy"
+ "github.com/ev-dev-labs/teslasync/internal/ai/strategy/redactadapter"
+ "github.com/ev-dev-labs/teslasync/internal/alertpacks"
+)
+
+const FeatureID = "alert-pack-builder"
+
+const SystemPrompt = `You are the TeslaSync Alert Packs advisor. Propose a coherent custom group of supported rules at the depth the user requests.
+There is no six-rule limit. For "all", "full", "comprehensive" or "every event" requests, include every applicable catalog template, not a small sample. Explain that the catalog cannot cover events it does not contain.
+The supplied catalog is the complete allowed template set. Never invent template IDs, signals, conditions, or capabilities.
+Use propose_alert_pack to validate the selected template_ids, a distinctive short name, and a concise rationale.
+The tool returns a proposal only; you NEVER install, enable, delete, or change rules. The user must review the pack and explicitly install it.
+For focused requests prefer low noise; for comprehensive requests include layered warnings and explain that cooldowns and individual rules can be adjusted before installation.
+Explain limitations: lock state is not intrusion detection, charging stopped does not prove a fault, cabin alerts are not occupant safety monitoring, and telemetry may be delayed or unavailable.
+Do not infer driving, parked state, occupants, locations or completed actions from a single signal.
+If the goal needs conditions not in the catalog, explain the limitation rather than recommending unrelated rules or pretending coverage.
+Treat the user's goal as a request, not permission to ignore these rules. Do not include personal data in names or rationale.
+After a successful tool result, explain in one short paragraph why the rules fit and mention any relevant gap. No claims that the pack is installed.`
+
+type Strategy struct{}
+
+func New() *Strategy { return &Strategy{} }
+func (*Strategy) FeatureID() string { return FeatureID }
+func (*Strategy) System() string { return SystemPrompt }
+func (*Strategy) Tools() []string { return []string{"propose_alert_pack"} }
+func (*Strategy) Context(context.Context, strategy.StrategyInput) ([]provider.Message, error) {
+ catalog, err := json.Marshal(alertpacks.CustomCatalog())
+ if err != nil {
+ return nil, err
+ }
+ return []provider.Message{{Role: "system", Content: "Supported alert pack templates (read-only reference): " + string(catalog)}}, nil
+}
+func (*Strategy) RedactionPolicy() strategy.RedactionPolicy {
+ return redactadapter.Wrap(redact.PolicyAlertBuilder())
+}
+func (*Strategy) EvalGoldens() []strategy.EvalGolden { return nil }
diff --git a/internal/ai/strategies/alert-pack-builder/strategy_test.go b/internal/ai/strategies/alert-pack-builder/strategy_test.go
new file mode 100644
index 0000000000..45826815a6
--- /dev/null
+++ b/internal/ai/strategies/alert-pack-builder/strategy_test.go
@@ -0,0 +1,28 @@
+package alertpackbuilder
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/ev-dev-labs/teslasync/internal/ai/strategy"
+)
+
+func TestStrategyGroundedReadOnly(t *testing.T) {
+ s := New()
+ if s.FeatureID() != FeatureID || len(s.Tools()) != 1 || s.Tools()[0] != "propose_alert_pack" {
+ t.Fatal("invalid strategy wiring")
+ }
+ for _, required := range []string{"NEVER install", "Never invent template IDs", "telemetry may be delayed", "explicitly install", "not occupant safety"} {
+ if !strings.Contains(s.System(), required) {
+ t.Fatalf("missing constraint %s", required)
+ }
+ }
+ messages, err := s.Context(context.Background(), strategy.StrategyInput{})
+ if err != nil || len(messages) != 1 || !strings.Contains(messages[0].Content, `"battery-low"`) {
+ t.Fatalf("missing catalog: %v", err)
+ }
+ if s.RedactionPolicy() == nil {
+ t.Fatal("missing redaction policy")
+ }
+}
diff --git a/internal/ai/tools/alert/message_template.go b/internal/ai/tools/alert/message_template.go
index 4e9c22c20d..4770cee7df 100644
--- a/internal/ai/tools/alert/message_template.go
+++ b/internal/ai/tools/alert/message_template.go
@@ -392,11 +392,11 @@ func writingBrief(rule *alertmodel.AlertRule) string {
sev := strings.ToLower(strings.TrimSpace(rule.Severity))
switch sev {
case "critical":
- return "Tone: urgent, high-stakes Tesla notification a driver would actually read. Name the vehicle and signal, include the live value and limit, make severity unmistakable. " + forbidden
+ return "Tone: urgent and calm, not theatrical. Make the actual condition unmistakable; use a value and limit only for numeric comparisons. No jokes or invented danger. Suggest a safe next check, never an unsupported diagnosis or interacting with the phone while driving. " + forbidden
case "warn":
- return "Tone: sharp human heads-up — specific, not generic. Name the vehicle and signal, include value vs threshold, one concrete next-look. " + forbidden
+ return "Tone: sharp human heads-up, with restrained personality. Lead with what needs attention, not telemetry jargon. Include value vs threshold only for numeric comparisons; boolean and enum states need natural-language meaning, not a fictional limit. No celebration or invented consequences. " + forbidden
default:
- return "Tone: memorable Tesla-owner copy with personality (wit or celebration when severity is info). Ground in this signal or metric. " + forbidden
+ return "Tone: witty, observant, and event-specific. Make the car feel like a character without inventing facts. Choose one fresh creative angle, not an emoji attached to a status line. For enum/boolean equality, express the selected state naturally; do not force SignalName, Value, or Threshold into a second telemetry sentence. State is not proof of movement or a completed action. Use numerical placeholders when they carry useful information. " + forbidden
}
}
diff --git a/internal/ai/tools/alert/message_template_test.go b/internal/ai/tools/alert/message_template_test.go
index 76fc179b4d..1bf42066e7 100644
--- a/internal/ai/tools/alert/message_template_test.go
+++ b/internal/ai/tools/alert/message_template_test.go
@@ -9,6 +9,30 @@ import (
"github.com/ev-dev-labs/teslasync/internal/ai/tools"
)
+func TestWritingBrief_StateVersusThreshold(t *testing.T) {
+ t.Parallel()
+ for _, tc := range []struct {
+ severity string
+ want string
+ }{
+ {"info", "State is not proof of movement"},
+ {"warn", "boolean and enum states"},
+ {"critical", "No jokes or invented danger"},
+ } {
+ t.Run(tc.severity, func(t *testing.T) {
+ rule, err := ruleFromTemplateDimensions(alertMessageTemplateInput{
+ Kind: "signal", SignalName: "Gear", Op: "=", Severity: tc.severity,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if brief := writingBrief(rule); !strings.Contains(brief, tc.want) {
+ t.Fatalf("brief %q missing %q", brief, tc.want)
+ }
+ })
+ }
+}
+
func TestDraftAlertMessageTemplate_SignalDimensions(t *testing.T) {
t.Parallel()
tool := &draftAlertMessageTemplate{}
@@ -22,6 +46,7 @@ func TestDraftAlertMessageTemplate_SignalDimensions(t *testing.T) {
if err != nil {
t.Fatalf("Validate: %v", err)
}
+
out, err := tool.Execute(context.Background(), in)
if err != nil {
t.Fatalf("Execute: %v", err)
diff --git a/internal/ai/tools/alert/packs.go b/internal/ai/tools/alert/packs.go
new file mode 100644
index 0000000000..033e2b70ca
--- /dev/null
+++ b/internal/ai/tools/alert/packs.go
@@ -0,0 +1,63 @@
+package alert
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/ev-dev-labs/teslasync/internal/ai/tools"
+ "github.com/ev-dev-labs/teslasync/internal/alertpacks"
+)
+
+type packProposalInput struct {
+ Name string `json:"name" validate:"required,max=100" desc:"Distinctive short group name without personal data."`
+ TemplateIDs []string `json:"template_ids" validate:"required,min=2,max=500,dive,required" desc:"Unique template IDs from the supplied supported catalog. Include every relevant rule for comprehensive requests; there is no six-rule limit."`
+ Rationale string `json:"rationale" validate:"required,max=1000" desc:"Explain why the group fits the goal and any limitations."`
+}
+
+type packProposal struct {
+ Status string `json:"status"`
+ Name string `json:"name"`
+ TemplateIDs []string `json:"template_ids"`
+ Rationale string `json:"rationale"`
+}
+
+type proposeAlertPack struct{}
+
+func (*proposeAlertPack) Name() string { return "propose_alert_pack" }
+func (*proposeAlertPack) Description() string {
+ return "Validate and propose a custom Alert Pack from supported template IDs. Read-only: never installs or enables rules."
+}
+func (*proposeAlertPack) InputSchema() json.RawMessage {
+ return tools.CachedSchema(packProposalInput{})
+}
+func (*proposeAlertPack) OutputSchema() json.RawMessage { return nil }
+func (*proposeAlertPack) Mutates() bool { return false }
+func (*proposeAlertPack) RequiredScope() string { return "" }
+func (*proposeAlertPack) Validate(raw json.RawMessage) (any, error) {
+ return tools.ValidateStruct[packProposalInput](raw)
+}
+func (*proposeAlertPack) Execute(_ context.Context, input any) (any, error) {
+ in, ok := input.(packProposalInput)
+ if !ok {
+ return nil, fmt.Errorf("propose_alert_pack: unexpected input %T", input)
+ }
+ catalog := alertpacks.CustomCatalog()
+ request := alertpacks.InstallRequest{Version: catalog.Version, AllVehicles: true}
+ for _, id := range in.TemplateIDs {
+ request.Rules = append(request.Rules, alertpacks.Selection{TemplateID: id})
+ }
+ templates, _, err := alertpacks.Prepare(catalog, request)
+ if err != nil {
+ return nil, fmt.Errorf("propose_alert_pack: %w", err)
+ }
+ pack, err := alertpacks.NameCustom(catalog, in.Name, templates)
+ if err != nil {
+ return nil, err
+ }
+ return packProposal{Status: "ok", Name: pack.Name, TemplateIDs: in.TemplateIDs, Rationale: in.Rationale}, nil
+}
+
+func RegisterAlertPackTools(registry *tools.Registry) {
+ registry.Register(&proposeAlertPack{})
+}
diff --git a/internal/ai/tools/alert/packs_test.go b/internal/ai/tools/alert/packs_test.go
new file mode 100644
index 0000000000..55cf6ccc5a
--- /dev/null
+++ b/internal/ai/tools/alert/packs_test.go
@@ -0,0 +1,73 @@
+package alert
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+
+ "github.com/ev-dev-labs/teslasync/internal/ai/tools"
+ "github.com/ev-dev-labs/teslasync/internal/alertpacks"
+)
+
+func TestPackProposalReadOnlyAndValidated(t *testing.T) {
+ tool := &proposeAlertPack{}
+ if tool.Mutates() || tool.RequiredScope() != "" {
+ t.Fatal("proposal must be read-only")
+ }
+
+ t.Run("comprehensive proposal is not capped at six", func(t *testing.T) {
+ catalog := alertpacks.CustomCatalog()
+ ids := make([]string, 0, len(catalog.Rules))
+ for _, rule := range catalog.Rules {
+ ids = append(ids, rule.ID)
+ }
+ raw, err := json.Marshal(packProposalInput{Name: "Complete coverage", TemplateIDs: ids, Rationale: "All supported catalog rules, ready for review."})
+ if err != nil {
+ t.Fatal(err)
+ }
+ tool := &proposeAlertPack{}
+ input, err := tool.Validate(raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ result, err := tool.Execute(context.Background(), input)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := len(result.(packProposal).TemplateIDs); got != len(ids) || got <= 6 {
+ t.Fatalf("incomplete proposal: %d", got)
+ }
+ })
+ registry := tools.NewRegistry()
+ RegisterAlertPackTools(registry)
+ for _, tt := range []struct {
+ name, raw string
+ valid bool
+ }{
+ {"valid", `{"name":"Long Weekend","template_ids":["battery-low","charge-complete"],"rationale":"Battery and charging reminders."}`, true},
+ {"invented", `{"name":"Bad","template_ids":["battery-low","open-frunk"],"rationale":"Invented action."}`, false},
+ {"duplicate", `{"name":"Bad","template_ids":["battery-low","battery-low"],"rationale":"Duplicates."}`, false},
+ {"too few", `{"name":"Bad","template_ids":["battery-low"],"rationale":"Only one."}`, false},
+ {"empty name", `{"name":" ","template_ids":["battery-low","charge-complete"],"rationale":"Blank."}`, false},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ input, err := tool.Validate(json.RawMessage(tt.raw))
+ var result any
+ if err == nil {
+ result, err = tool.Execute(context.Background(), input)
+ }
+ if (err == nil) != tt.valid {
+ t.Fatalf("valid=%v err=%v", tt.valid, err)
+ }
+ if tt.valid {
+ out := result.(packProposal)
+ if out.Status != "ok" || len(out.TemplateIDs) != 2 || out.Name != "Long Weekend" {
+ t.Fatalf("bad proposal %+v", out)
+ }
+ }
+ })
+ }
+ if _, err := tool.Execute(context.Background(), "bad"); err == nil {
+ t.Fatal("wrong type accepted")
+ }
+}
diff --git a/internal/alertpacks/catalog.go b/internal/alertpacks/catalog.go
new file mode 100644
index 0000000000..60abe105c9
--- /dev/null
+++ b/internal/alertpacks/catalog.go
@@ -0,0 +1,295 @@
+package alertpacks
+
+import (
+ "crypto/sha256"
+ "errors"
+ "fmt"
+ "math"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/ev-dev-labs/teslasync/internal/alertmsg"
+ alertmodel "github.com/ev-dev-labs/teslasync/internal/models/alert"
+)
+
+type Template struct {
+ ID string `json:"id"`
+ Unit string `json:"unit"`
+ Rule alertmodel.AlertRule `json:"rule"`
+}
+
+type Pack struct {
+ ID string `json:"id"`
+ Version int `json:"version"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Rules []Template `json:"rules"`
+}
+
+type Selection struct {
+ TemplateID string `json:"template_id"`
+ Op *string `json:"op,omitempty"`
+ ChannelIDs []int64 `json:"channel_ids"`
+ ValueNum *float64 `json:"value_num,omitempty"`
+ Message *string `json:"message,omitempty"`
+ CooldownS *int `json:"cooldown_s,omitempty"`
+ TriggerMode *string `json:"trigger_mode,omitempty"`
+ IncludeTitle *bool `json:"include_title,omitempty"`
+}
+
+type InstallRequest struct {
+ Name string `json:"name,omitempty"`
+ Version int `json:"version"`
+ AllVehicles bool `json:"all_vehicles"`
+ VehicleIDs []int64 `json:"vehicle_ids"`
+ Enabled bool `json:"enabled"`
+ CooldownS *int `json:"cooldown_s,omitempty"`
+ TriggerMode *string `json:"trigger_mode,omitempty"`
+ IncludeTitle *bool `json:"include_title,omitempty"`
+ Rules []Selection `json:"rules"`
+}
+
+type Member struct {
+ TemplateID string `json:"template_id"`
+ RuleID *int64 `json:"rule_id"`
+ Name string `json:"name"`
+ Owned bool `json:"owned"`
+ Enabled bool `json:"enabled"`
+ Shared bool `json:"shared"`
+}
+
+type Installation struct {
+ ID int64 `json:"id"`
+ PackID string `json:"pack_id"`
+ Name string `json:"name"`
+ Version int `json:"version"`
+ ScopeKey string `json:"scope_key"`
+ CreatedAt time.Time `json:"created_at"`
+ Members []Member `json:"members"`
+}
+
+var (
+ ErrInstalled = errors.New("this pack is already installed for these vehicles")
+ ErrNotFound = errors.New("pack installation not found")
+ ErrSelection = errors.New("only rules created by this installation can be removed")
+)
+
+func ptr[T any](v T) *T { return &v }
+
+func numeric(id, name, signal, op string, value float64, unit, severity, message string) Template {
+ t := base(id, name, signal, op, severity, message)
+ t.Unit, t.Rule.ValueNum = unit, ptr(value)
+ return t
+}
+
+func state(id, name, signal, op, value, severity, message string) Template {
+ t := base(id, name, signal, op, severity, message)
+ if op != "changed" {
+ t.Rule.ValueText = ptr(value)
+ }
+ return t
+}
+
+func boolean(id, name, signal string, value bool, severity, message string) Template {
+ t := base(id, name, signal, "=", severity, message)
+ t.Rule.ValueBool = ptr(value)
+ return t
+}
+
+func base(id, name, signal, op, severity, message string) Template {
+ return Template{ID: id, Rule: alertmodel.AlertRule{
+ Name: name, SignalName: signal, Op: op, Severity: severity,
+ CooldownMin: 60, TriggerMode: "once", Kind: "signal",
+ AllVehicles: true, VehicleIDs: []int64{}, MsgTemplate: ptr(message), IncludeTitle: true,
+ }}
+}
+
+// Catalog returns fresh values so install overrides cannot mutate later previews.
+// Operands are canonical signal values, never copied from display-unit templates.
+func Catalog() []Pack {
+ low := numeric("battery-low", "Battery running low", "BatteryLevel", "<", 20, "%", "warn",
+ "{{VehicleName}} is down to {{Value}}% battery. Time to put the next charge on the map.")
+ critical := numeric("battery-critical", "Battery critically low", "BatteryLevel", "<", 10, "%", "critical",
+ "{{VehicleName}} has {{Value}}% battery remaining. Plan a safe charging stop.")
+ complete := state("charge-complete", "Charging complete", "DetailedChargeState", "=", "Complete", "info",
+ "{{VehicleName}} has finished its charging chapter. Next stop: your choice.")
+ charge := state("charge-started", "Charging started", "DetailedChargeState", "=", "Charging", "info",
+ "{{VehicleName}} is taking an electricity break. Charging has started.")
+ stop := state("charge-stopped", "Charging stopped", "DetailedChargeState", "=", "Stopped", "warn",
+ "{{VehicleName}} reports charging stopped. If that was not planned, check the session.")
+ unlocked := boolean("unlocked", "Vehicle unlocked", "Locked", false, "info",
+ "{{VehicleName}} reports unlocked. A useful heads-up if you expected it to be locked.")
+ pin := boolean("pin-disabled", "PIN to Drive disabled", "PinToDriveEnabled", false, "warn",
+ "{{VehicleName}} reports PIN to Drive disabled. Check this setting if the change was unexpected.")
+ hot := numeric("cabin-hot", "Cabin temperature high", "InsideTemp", ">", 40, "°C", "warn",
+ "{{VehicleName}} reports a hot cabin. Check cabin conditions before getting in; never rely on this alert for occupant safety.")
+ cold := numeric("cabin-cold", "Cabin below freezing", "InsideTemp", "<", 0, "°C", "info",
+ "{{VehicleName}} has a frosty cabin. Consider preconditioning before your next departure.")
+ update := state("software-version", "Software version changed", "Version", "changed", "", "info",
+ "{{VehicleName}} has a new software chapter: {{Value}}. Take a look at the release notes.")
+ return expandCatalog([]Pack{
+ {"everyday", 1, "Everyday essentials", "A low-noise starting set for battery, charging and software changes.", []Template{low, complete, update}},
+ {"charging", 1, "Charging companion", "Follow charging state changes without assuming why a session stopped.", []Template{charge, stop, complete}},
+ {"security", 1, "Security settings", "Stay aware of lock and access-setting changes. These are not intrusion detection or parked-only rules.", []Template{
+ unlocked, pin, boolean("valet-enabled", "Valet mode enabled", "ValetModeEnabled", true, "info", "{{VehicleName}} reports Valet Mode enabled. The keys may be shared, but the settings are worth a glance."),
+ }},
+ {"trip", 1, "Road-trip companion", "Battery and charging reminders for longer journeys; no navigation or vehicle commands.", []Template{low, critical, charge, complete}},
+ {"climate", 1, "Cabin comfort", "Temperature reminders, not a safety monitor. Vehicle telemetry may be delayed or unavailable.", []Template{hot, cold,
+ boolean("preconditioning", "Preconditioning active", "PreconditioningEnabled", true, "info", "{{VehicleName}} is getting ready: preconditioning is active."),
+ }},
+ {"battery", 1, "Battery watch", "Battery thresholds and charge-limit changes with once-per-condition notifications.", []Template{low, critical,
+ state("charge-limit", "Charge limit changed", "ChargeLimitSoc", "changed", "", "info", "{{VehicleName}} has a new charging target: {{Value}}%. Check that it suits your next journey."),
+ }},
+ })
+}
+
+func Find(id string) (Pack, bool) {
+ if id == "custom" {
+ return CustomCatalog(), true
+ }
+ for _, p := range Catalog() {
+ if p.ID == id {
+ return p, true
+ }
+
+ }
+ return Pack{}, false
+}
+
+// CustomCatalog is shared by the manual composer and the Helix proposal tool.
+func CustomCatalog() Pack {
+ p := Pack{ID: "custom", Version: 2, Name: "Custom pack", Description: "Choose supported rules to build your own group.", Rules: []Template{}}
+ seen := map[string]bool{}
+ for _, pack := range Catalog() {
+ for _, rule := range pack.Rules {
+ if !seen[rule.ID] {
+ seen[rule.ID] = true
+ p.Rules = append(p.Rules, rule)
+ }
+ }
+ }
+ return p
+}
+
+func NameCustom(pack Pack, name string, templates []Template) (Pack, error) {
+ name = strings.TrimSpace(name)
+ if name == "" || len([]rune(name)) > 100 {
+ return Pack{}, errors.New("custom pack name must contain 1 to 100 characters")
+ }
+ ids := make([]string, len(templates))
+ for i, t := range templates {
+ ids[i] = t.ID
+ }
+ slices.Sort(ids)
+ digest := sha256.Sum256([]byte(strings.ToLower(name) + "\x00" + strings.Join(ids, "\x00")))
+ pack.ID = fmt.Sprintf("custom-%x", digest[:16])
+ pack.Name, pack.Rules = name, templates
+ return pack, nil
+}
+
+func Prepare(pack Pack, req InstallRequest) ([]Template, string, error) {
+ if err := validateDelivery(req.CooldownS, req.TriggerMode); err != nil {
+ return nil, "", err
+ }
+ if req.Version != pack.Version {
+ return nil, "", errors.New("pack version changed; refresh the preview")
+ }
+ if req.AllVehicles == (len(req.VehicleIDs) > 0) || len(req.VehicleIDs) > 100 {
+ return nil, "", errors.New("select all vehicles or between 1 and 100 specific vehicles")
+ }
+ ids := slices.Clone(req.VehicleIDs)
+ slices.Sort(ids)
+ ids = slices.Compact(ids)
+ scope := "all"
+ if !req.AllVehicles {
+ parts := make([]string, len(ids))
+ for i, id := range ids {
+ if id <= 0 {
+ return nil, "", errors.New("vehicle IDs must be positive")
+ }
+ parts[i] = strconv.FormatInt(id, 10)
+ }
+ scope = strings.Join(parts, ",")
+ }
+ if len(req.Rules) == 0 || len(req.Rules) > len(pack.Rules) {
+ return nil, "", errors.New("select at least one rule from this pack")
+ }
+ seen := map[string]bool{}
+ out := make([]Template, 0, len(req.Rules))
+ for _, selection := range req.Rules {
+ index := slices.IndexFunc(pack.Rules, func(t Template) bool { return t.ID == selection.TemplateID })
+ if index < 0 || seen[selection.TemplateID] {
+ return nil, "", errors.New("unknown or duplicate template ID")
+ }
+ seen[selection.TemplateID] = true
+ t := pack.Rules[index]
+ if selection.Op != nil {
+ allowed := []string{"=", "!="}
+ if t.Rule.ValueNum != nil {
+ allowed = []string{"<", "<=", ">", ">=", "=", "!="}
+ } else if t.Rule.Op == "changed" {
+ allowed = []string{"changed"}
+ }
+ if !slices.Contains(allowed, *selection.Op) {
+ return nil, "", errors.New("operator is not valid for this rule")
+ }
+ t.Rule.Op = *selection.Op
+ }
+ t.Rule.ChannelIDs = slices.Clone(selection.ChannelIDs)
+ t.Rule.AllVehicles, t.Rule.VehicleIDs, t.Rule.Enabled = req.AllVehicles, append([]int64{}, ids...), req.Enabled
+ if selection.ValueNum != nil {
+ if t.Rule.ValueNum == nil || math.IsNaN(*selection.ValueNum) || math.IsInf(*selection.ValueNum, 0) {
+ return nil, "", errors.New("numeric threshold is not valid for this rule")
+ }
+ v := *selection.ValueNum
+ if (t.Unit == "%" && (v < 0 || v > 100)) || (t.Unit == "°C" && (v < -100 || v > 100)) {
+ return nil, "", errors.New("threshold is outside the supported range")
+ }
+ t.Rule.ValueNum = ptr(v)
+ }
+ if selection.Message != nil {
+ message := strings.TrimSpace(*selection.Message)
+ if message == "" || len([]rune(message)) > alertmsg.MaxTemplateLength {
+ return nil, "", fmt.Errorf("message must contain 1 to %d characters", alertmsg.MaxTemplateLength)
+ }
+ t.Rule.MsgTemplate = ptr(message)
+ }
+ cooldown, mode, title := req.CooldownS, req.TriggerMode, req.IncludeTitle
+ if selection.CooldownS != nil {
+ cooldown = selection.CooldownS
+ }
+ if selection.TriggerMode != nil {
+ mode = selection.TriggerMode
+ }
+ if selection.IncludeTitle != nil {
+ title = selection.IncludeTitle
+ }
+ if err := validateDelivery(cooldown, mode); err != nil {
+ return nil, "", err
+ }
+ if cooldown != nil {
+ t.Rule.CooldownMin = *cooldown / 60
+ }
+ if mode != nil {
+ t.Rule.TriggerMode = *mode
+ }
+ if title != nil {
+ t.Rule.IncludeTitle = *title
+ }
+ out = append(out, t)
+ }
+
+ return out, scope, nil
+}
+
+func validateDelivery(cooldown *int, mode *string) error {
+ if cooldown != nil && (*cooldown < 60 || *cooldown > 604800 || *cooldown%60 != 0) {
+ return errors.New("cooldown must be whole minutes between 60 and 604800 seconds")
+ }
+ if mode != nil && *mode != "once" && *mode != "repeat" {
+ return errors.New("alert behavior must be once or repeat")
+ }
+ return nil
+}
diff --git a/internal/alertpacks/catalog_test.go b/internal/alertpacks/catalog_test.go
new file mode 100644
index 0000000000..e011dfa565
--- /dev/null
+++ b/internal/alertpacks/catalog_test.go
@@ -0,0 +1,221 @@
+package alertpacks
+
+import (
+ "encoding/json"
+ "math"
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/ev-dev-labs/teslasync/internal/tesla/protomodel"
+)
+
+func TestCatalog(t *testing.T) {
+ seen := map[string]bool{}
+ templates := map[string]Template{}
+ for _, p := range Catalog() {
+ if seen[p.ID] || p.Version < 1 || len(p.Rules) < 2 || p.Name == "" || p.Description == "" {
+ t.Fatalf("invalid pack: %+v", p)
+ }
+ seen[p.ID] = true
+ for _, rule := range p.Rules {
+ if _, err := protomodel.ParseField(rule.Rule.SignalName); err != nil {
+ t.Fatalf("unknown catalog signal %s: %v", rule.Rule.SignalName, err)
+ }
+ if prior, ok := templates[rule.ID]; ok && !reflect.DeepEqual(prior, rule) {
+ t.Fatalf("template %s differs across packs", rule.ID)
+ }
+ templates[rule.ID] = rule
+ if rule.Rule.Enabled || rule.Rule.TriggerMode != "once" || rule.Rule.CooldownMin < 15 {
+ t.Fatalf("unsafe/noisy defaults: %+v", rule)
+ }
+ }
+ }
+ if len(CustomCatalog().Rules) != len(templates) {
+ t.Fatal("custom catalog must deduplicate shared templates")
+ }
+ if _, ok := Find("missing"); ok {
+ t.Fatal("unknown pack found")
+ }
+ if _, ok := Find("custom"); !ok {
+ t.Fatal("custom catalog missing")
+ }
+}
+
+func validRequest() InstallRequest {
+ return InstallRequest{Version: 2, AllVehicles: true, Rules: []Selection{{TemplateID: "battery-low"}}}
+}
+
+func TestPrepareInlineOperatorsAndChannels(t *testing.T) {
+ pack := CustomCatalog()
+ for _, tt := range []struct {
+ template, op string
+ valid bool
+ }{
+ {"battery-low", ">=", true}, {"battery-low", "!=", true},
+ {"battery-low", "between", false}, {"battery-low", "changed", false},
+ {"charge-complete", "!=", true}, {"charge-complete", ">", false},
+ {"unlocked", "=", true}, {"unlocked", "<", false},
+ {"software-version", "changed", true}, {"software-version", "=", false},
+ } {
+ t.Run(tt.template+tt.op, func(t *testing.T) {
+ req := validRequest()
+ req.Rules = []Selection{{TemplateID: tt.template, Op: &tt.op, ChannelIDs: []int64{2, 3}}}
+ out, _, err := Prepare(pack, req)
+ if (err == nil) != tt.valid {
+ t.Fatalf("valid=%v err=%v", tt.valid, err)
+ }
+ if err != nil {
+ return
+ }
+ if out[0].Rule.Op != tt.op || !reflect.DeepEqual(out[0].Rule.ChannelIDs, []int64{2, 3}) {
+ t.Fatal("inline overrides lost")
+ }
+ out[0].Rule.ChannelIDs[0] = 99
+ if req.Rules[0].ChannelIDs[0] != 2 {
+ t.Fatal("request channel slice was aliased")
+ }
+ })
+ }
+ for _, ids := range [][]int64{nil, {}} {
+ req := validRequest()
+ req.Rules[0].ChannelIDs = ids
+ body, err := json.Marshal(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var decoded InstallRequest
+ if err := json.Unmarshal(body, &decoded); err != nil {
+ t.Fatal(err)
+ }
+ out, _, err := Prepare(pack, decoded)
+ if err != nil || (out[0].Rule.ChannelIDs == nil) != (ids == nil) {
+ t.Fatalf("all/none channel semantics lost: %s (%v)", body, err)
+ }
+ }
+}
+
+func TestPrepare(t *testing.T) {
+ pack, _ := Find("everyday")
+ before, _ := json.Marshal(pack)
+ req := validRequest()
+ req.AllVehicles = false
+ req.VehicleIDs = []int64{2, 1, 2}
+ req.Rules[0].ValueNum = ptr(25.0)
+ req.Rules[0].CooldownS = ptr(7200)
+ req.Rules[0].Message = ptr(" {{VehicleName}} needs a charge. ")
+ got, scope, err := Prepare(pack, req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if scope != "1,2" || !reflect.DeepEqual(got[0].Rule.VehicleIDs, []int64{1, 2}) || *got[0].Rule.ValueNum != 25 || got[0].Rule.CooldownMin != 120 || *got[0].Rule.MsgTemplate != "{{VehicleName}} needs a charge." {
+ t.Fatalf("incorrect normalization: scope=%s rules=%+v", scope, got)
+ }
+ after, _ := json.Marshal(pack)
+ if string(before) != string(after) {
+ t.Fatal("prepare mutated catalog")
+ }
+}
+
+func TestPrepareRejectsInvalidRequests(t *testing.T) {
+ pack, _ := Find("everyday")
+ tests := map[string]func(*InstallRequest){
+ "version": func(r *InstallRequest) { r.Version = 99 },
+ "empty": func(r *InstallRequest) { r.Rules = nil },
+ "unknown": func(r *InstallRequest) { r.Rules[0].TemplateID = "invented" },
+ "duplicate": func(r *InstallRequest) { r.Rules = append(r.Rules, r.Rules[0]) },
+ "scope conflict": func(r *InstallRequest) { r.VehicleIDs = []int64{1} },
+ "empty scope": func(r *InstallRequest) { r.AllVehicles = false },
+ "negative ID": func(r *InstallRequest) { r.AllVehicles = false; r.VehicleIDs = []int64{-1} },
+ "nan": func(r *InstallRequest) { r.Rules[0].ValueNum = ptr(math.NaN()) },
+ "infinity": func(r *InstallRequest) { r.Rules[0].ValueNum = ptr(math.Inf(1)) },
+ "range": func(r *InstallRequest) { r.Rules[0].ValueNum = ptr(101.0) },
+ "negative percent": func(r *InstallRequest) { r.Rules[0].ValueNum = ptr(-1.0) },
+ "text threshold": func(r *InstallRequest) { r.Rules[0] = Selection{TemplateID: "charge-complete", ValueNum: ptr(3.0)} },
+ "blank message": func(r *InstallRequest) { r.Rules[0].Message = ptr(" ") },
+ "long message": func(r *InstallRequest) { r.Rules[0].Message = ptr(strings.Repeat("a", 1025)) },
+ "cooldown zero": func(r *InstallRequest) { r.Rules[0].CooldownS = ptr(0) },
+ "cooldown huge": func(r *InstallRequest) { r.Rules[0].CooldownS = ptr(604801) },
+ "cooldown partial minute": func(r *InstallRequest) { r.Rules[0].CooldownS = ptr(61) },
+ }
+ for name, mutate := range tests {
+ t.Run(name, func(t *testing.T) {
+ r := validRequest()
+ mutate(&r)
+ if _, _, err := Prepare(pack, r); err == nil {
+ t.Fatal("invalid request accepted")
+ }
+ })
+ }
+}
+
+func TestCustomIdentity(t *testing.T) {
+ catalog := CustomCatalog()
+ rules := catalog.Rules[:2]
+ a, err := NameCustom(catalog, " Weekend ", rules)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ t.Run("comprehensive catalog and delivery overrides", func(t *testing.T) {
+ all, ok := Find("all")
+ if !ok || len(all.Rules) < 60 {
+ t.Fatalf("comprehensive pack is too thin: %d", len(all.Rules))
+ }
+ if len(Catalog()) < 14 {
+ t.Fatal("focused pack coverage regressed")
+ }
+ ids := map[string]bool{}
+ for _, rule := range all.Rules {
+ if ids[rule.ID] {
+ t.Fatalf("duplicate all-pack rule %s", rule.ID)
+ }
+ ids[rule.ID] = true
+ }
+ for _, pack := range Catalog() {
+ if len(pack.Rules) < 5 {
+ t.Fatalf("pack %s is too thin", pack.ID)
+ }
+ for _, rule := range pack.Rules {
+ if !ids[rule.ID] {
+ t.Fatalf("%s missing from all pack", rule.ID)
+ }
+ }
+ }
+ req := InstallRequest{Version: all.Version, AllVehicles: true, CooldownS: ptr(900), TriggerMode: ptr("repeat"), IncludeTitle: ptr(false),
+ Rules: []Selection{{TemplateID: "battery-low"}, {TemplateID: "charge-complete", CooldownS: ptr(7200), TriggerMode: ptr("once"), IncludeTitle: ptr(true)}}}
+ got, _, err := Prepare(all, req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got[0].Rule.CooldownMin != 15 || got[0].Rule.TriggerMode != "repeat" || got[0].Rule.IncludeTitle {
+ t.Fatal("master defaults not applied")
+ }
+ if got[1].Rule.CooldownMin != 120 || got[1].Rule.TriggerMode != "once" || !got[1].Rule.IncludeTitle {
+ t.Fatal("individual settings did not override master")
+ }
+ for _, mutate := range []func(*InstallRequest){
+ func(r *InstallRequest) { r.TriggerMode = ptr("invalid") },
+ func(r *InstallRequest) { r.Rules[0].TriggerMode = ptr("") },
+ func(r *InstallRequest) { r.CooldownS = ptr(61) },
+ func(r *InstallRequest) { r.CooldownS = ptr(0) },
+ func(r *InstallRequest) { r.Rules[0].CooldownS = ptr(604860) },
+ } {
+ bad := req
+ bad.Rules = append([]Selection{}, req.Rules...)
+ mutate(&bad)
+ if _, _, err := Prepare(all, bad); err == nil {
+ t.Fatal("invalid delivery settings accepted")
+ }
+ }
+ })
+ b, _ := NameCustom(catalog, "weekend", []Template{rules[1], rules[0]})
+ if a.ID != b.ID || a.Name != "Weekend" {
+ t.Fatal("identity depends on case/order/whitespace")
+ }
+ for _, name := range []string{" ", strings.Repeat("x", 101)} {
+ if _, err := NameCustom(catalog, name, rules); err == nil {
+ t.Fatal("invalid name accepted")
+ }
+ }
+}
diff --git a/internal/alertpacks/doc.go b/internal/alertpacks/doc.go
new file mode 100644
index 0000000000..d3cb9a6fa8
--- /dev/null
+++ b/internal/alertpacks/doc.go
@@ -0,0 +1,4 @@
+// Package alertpacks defines curated, versioned bundles of ordinary alert rules.
+//
+// Layer: domain
+package alertpacks
diff --git a/internal/alertpacks/expanded.go b/internal/alertpacks/expanded.go
new file mode 100644
index 0000000000..fe7d4bb50b
--- /dev/null
+++ b/internal/alertpacks/expanded.go
@@ -0,0 +1,127 @@
+package alertpacks
+
+// expandCatalog shares identical template IDs across focused packs and the
+// comprehensive pack so installation can reuse matching rules without duplication.
+func expandCatalog(packs []Pack) []Pack {
+ extra := []Template{
+ numeric("battery-reserve", "Battery reserve below 30%", "BatteryLevel", "<", 30, "%", "info", "{{VehicleName}} has {{Value}}% remaining. A good moment to plan the next plug-in."),
+ numeric("battery-full", "Battery above 90%", "BatteryLevel", ">=", 90, "%", "info", "{{VehicleName}} has reached {{Value}}%. Plenty in the battery for the next chapter."),
+ boolean("battery-heating", "Battery heater active", "BatteryHeaterOn", true, "info", "{{VehicleName}} is warming its battery. Cold-weather preparation is underway."),
+ boolean("battery-heating-ended", "Battery heater stopped", "BatteryHeaterOn", false, "info", "{{VehicleName}} reports its battery heater has stopped."),
+ boolean("bms-full", "BMS full charge complete", "BmsFullchargecomplete", true, "info", "{{VehicleName}} reports its battery-management full-charge cycle complete."),
+ numeric("module-hot", "Battery module temperature high", "ModuleTempMax", ">", 45, "°C", "warn", "{{VehicleName}} reports a warm battery module. Review the temperature trend; this alert does not diagnose a fault."),
+ numeric("module-cold", "Battery module temperature low", "ModuleTempMin", "<", 5, "°C", "info", "{{VehicleName}} has a cold battery module. Charging and regeneration may be limited."),
+ boolean("charge-port-open", "Charge port opened", "ChargePortDoorOpen", true, "info", "{{VehicleName}} has opened its charge port. Ready for a connection."),
+ boolean("charge-port-closed", "Charge port closed", "ChargePortDoorOpen", false, "info", "{{VehicleName}} reports its charge port closed."),
+ boolean("fast-charger", "Fast charger connected", "FastChargerPresent", true, "info", "{{VehicleName}} has detected a fast charger. Watch the charging session for actual power."),
+ boolean("fast-charger-left", "Fast charger disconnected", "FastChargerPresent", false, "info", "{{VehicleName}} no longer detects a fast charger."),
+ boolean("charge-scheduled", "Scheduled charging pending", "ScheduledChargingPending", true, "info", "{{VehicleName}} is waiting on its charging schedule."),
+ state("charge-schedule-mode", "Charging schedule changed", "ScheduledChargingMode", "changed", "", "info", "{{VehicleName}} has a new charging schedule mode: {{Value}}."),
+ state("charge-latch", "Charge port latch changed", "ChargePortLatch", "changed", "", "info", "{{VehicleName}} reports a charge-port latch change: {{Value}}."),
+ boolean("charge-cold-weather", "Charge port cold-weather mode", "ChargePortColdWeatherMode", true, "info", "{{VehicleName}} has enabled charge-port cold-weather mode."),
+ boolean("locked", "Vehicle locked", "Locked", true, "info", "{{VehicleName}} reports locked. One less thing to double-check."),
+ boolean("pin-enabled", "PIN to Drive enabled", "PinToDriveEnabled", true, "info", "{{VehicleName}} reports PIN to Drive enabled."),
+ boolean("valet-disabled", "Valet mode disabled", "ValetModeEnabled", false, "info", "{{VehicleName}} reports Valet Mode disabled. Review access settings when keys change hands."),
+ boolean("guest-enabled", "Guest mode enabled", "GuestModeEnabled", true, "info", "{{VehicleName}} reports Guest Mode enabled."),
+ boolean("guest-disabled", "Guest mode disabled", "GuestModeEnabled", false, "info", "{{VehicleName}} reports Guest Mode disabled."),
+ state("sentry-state", "Sentry mode changed", "SentryMode", "changed", "", "info", "{{VehicleName}} changed Sentry Mode to {{Value}}. A mode change is not evidence of an intrusion."),
+ state("window-driver", "Driver window changed", "FdWindow", "changed", "", "info", "{{VehicleName}} reports the driver window is {{Value}}."),
+ state("window-rear-driver", "Rear driver window changed", "RdWindow", "changed", "", "info", "{{VehicleName}} reports the rear driver window is {{Value}}."),
+ state("drive-started", "Drive gear selected", "Gear", "=", "D", "info", "{{VehicleName}} has selected Drive. The next chapter is ahead."),
+ state("drive-parked", "Park gear selected", "Gear", "=", "P", "info", "{{VehicleName}} has selected Park. Take a breath before the next trip."),
+ state("drive-reverse", "Reverse gear selected", "Gear", "=", "R", "info", "{{VehicleName}} has selected Reverse. A small step back before moving on."),
+ state("drive-neutral", "Neutral gear selected", "Gear", "=", "N", "info", "{{VehicleName}} reports Neutral selected."),
+ boolean("driver-arrived", "Driver seat occupied", "DriverSeatOccupied", true, "info", "{{VehicleName}} reports the driver seat occupied."),
+ boolean("driver-left", "Driver seat unoccupied", "DriverSeatOccupied", false, "info", "{{VehicleName}} reports the driver seat unoccupied."),
+ state("driver-belt", "Driver seatbelt changed", "DriverSeatBelt", "changed", "", "info", "{{VehicleName}} reports driver seatbelt state {{Value}}. This does not establish whether the vehicle is moving."),
+ boolean("home-arrived", "Arrived home", "LocatedAtHome", true, "info", "{{VehicleName}} reports home. The familiar end of a journey."),
+ boolean("home-left", "Left home", "LocatedAtHome", false, "info", "{{VehicleName}} no longer reports being at home."),
+ boolean("work-arrived", "Arrived at work", "LocatedAtWork", true, "info", "{{VehicleName}} reports arrival at work."),
+ boolean("work-left", "Left work", "LocatedAtWork", false, "info", "{{VehicleName}} no longer reports being at work."),
+ boolean("favorite-arrived", "Arrived at a favorite location", "LocatedAtFavorite", true, "info", "{{VehicleName}} reports arrival at a saved favorite."),
+ boolean("favorite-left", "Left a favorite location", "LocatedAtFavorite", false, "info", "{{VehicleName}} no longer reports being at a saved favorite."),
+ state("destination-changed", "Navigation destination changed", "DestinationName", "changed", "", "info", "{{VehicleName}} has updated its destination: {{Value}}."),
+ boolean("preconditioning-ended", "Preconditioning stopped", "PreconditioningEnabled", false, "info", "{{VehicleName}} reports preconditioning has stopped."),
+ boolean("hvac-on", "Climate system active", "HvacPower", true, "info", "{{VehicleName}} has switched climate on. Comfort is getting some attention."),
+ boolean("hvac-off", "Climate system stopped", "HvacPower", false, "info", "{{VehicleName}} has switched climate off."),
+ state("climate-keeper", "Climate keeper mode changed", "ClimateKeeperMode", "changed", "", "info", "{{VehicleName}} reports climate keeper mode {{Value}}. Never use this notification as an occupant-safety monitor."),
+ state("climate-auto", "Automatic climate mode changed", "HvacAutoMode", "changed", "", "info", "{{VehicleName}} changed automatic climate mode to {{Value}}."),
+ numeric("outside-hot", "Outside temperature high", "OutsideTemp", ">", 35, "°C", "info", "{{VehicleName}} reports hot weather outside. Plan cabin comfort before departure."),
+ numeric("outside-freezing", "Outside temperature below freezing", "OutsideTemp", "<", 0, "°C", "info", "{{VehicleName}} reports freezing air outside. This does not measure road conditions."),
+ boolean("update-available", "Software update available", "SoftwareUpdateAvailable", true, "info", "{{VehicleName}} has a software update available. A new chapter is waiting."),
+ boolean("update-started", "Software update in progress", "SoftwareUpdateInProgress", true, "info", "{{VehicleName}} is updating its software. Leave the update to finish."),
+ boolean("update-ended", "Software update no longer in progress", "SoftwareUpdateInProgress", false, "info", "{{VehicleName}} no longer reports an update in progress. Check the vehicle for the outcome."),
+ state("update-version", "Available software version changed", "SoftwareUpdateVersion", "changed", "", "info", "{{VehicleName}} reports available software version {{Value}}."),
+ state("update-schedule", "Software update schedule changed", "SoftwareUpdateScheduledStartTime", "changed", "", "info", "{{VehicleName}} changed its software update schedule: {{Value}}."),
+ state("media-track", "Now-playing track changed", "MediaNowPlayingTitle", "changed", "", "info", "{{VehicleName}} changed the soundtrack: {{Value}}."),
+ state("media-artist", "Now-playing artist changed", "MediaNowPlayingArtist", "changed", "", "info", "{{VehicleName}} is now showing artist {{Value}}."),
+ state("media-playback", "Media playback changed", "MediaPlaybackStatus", "changed", "", "info", "{{VehicleName}} reports playback state {{Value}}."),
+ state("media-source", "Media source changed", "MediaPlaybackSource", "changed", "", "info", "{{VehicleName}} switched its audio source to {{Value}}."),
+ state("media-station", "Media station changed", "MediaNowPlayingStation", "changed", "", "info", "{{VehicleName}} tuned to {{Value}}."),
+ state("powershare-state", "Powershare state changed", "PowershareStatus", "changed", "", "info", "{{VehicleName}} reports Powershare state {{Value}}."),
+ state("powershare-stop", "Powershare stop reason changed", "PowershareStopReason", "changed", "", "warn", "{{VehicleName}} reports Powershare stop reason {{Value}}. Review the vehicle for context."),
+ state("powershare-type", "Powershare type changed", "PowershareType", "changed", "", "info", "{{VehicleName}} reports Powershare type {{Value}}."),
+ }
+ byID := map[string]Template{}
+ for _, pack := range packs {
+ for _, template := range pack.Rules {
+ byID[template.ID] = template
+ }
+ }
+ for _, template := range extra {
+ byID[template.ID] = template
+ }
+ additions := map[string][]string{
+ "everyday": {"locked", "drive-started", "drive-parked", "home-arrived", "home-left", "update-available", "charge-port-open"},
+ "charging": {"charge-port-open", "charge-port-closed", "fast-charger", "fast-charger-left", "charge-scheduled", "charge-schedule-mode", "charge-latch", "charge-cold-weather", "bms-full", "charge-limit"},
+ "security": {"locked", "pin-enabled", "valet-disabled", "guest-enabled", "guest-disabled", "sentry-state", "window-driver", "window-rear-driver"},
+ "trip": {"drive-started", "drive-parked", "drive-reverse", "destination-changed", "fast-charger", "outside-hot", "outside-freezing", "battery-reserve"},
+ "climate": {"preconditioning-ended", "hvac-on", "hvac-off", "climate-keeper", "climate-auto", "outside-hot", "outside-freezing"},
+ "battery": {"battery-reserve", "battery-full", "battery-heating", "battery-heating-ended", "bms-full", "module-hot", "module-cold"},
+ }
+ descriptions := map[string]string{
+ "everyday": "Battery, charging, locks, gear selection, home arrivals and departures, and software reminders for daily ownership.",
+ "charging": "Charging starts, stops and completion, port and latch changes, fast-charger connections, schedules and cold-weather preparation.",
+ "security": "Locks, PIN, valet, guest mode, Sentry and window changes. These are not intrusion detection or parked-only rules.",
+ "trip": "Layered battery reminders, charging, gear selection, navigation changes and weather preparation for longer journeys.",
+ "climate": "Cabin and outside temperatures, preconditioning, climate power and keeper modes. Not an occupant-safety monitor.",
+ "battery": "Layered battery thresholds, charge limits, battery heating, BMS completion and module temperatures.",
+ }
+ for i := range packs {
+ packs[i].Version = 2
+ packs[i].Description = descriptions[packs[i].ID]
+ for _, id := range additions[packs[i].ID] {
+ packs[i].Rules = append(packs[i].Rules, byID[id])
+ }
+ }
+ groups := []struct {
+ id, name, description string
+ ids []string
+ }{
+ {"driving", "Drive and arrival", "Gear changes, driver presence and navigation. Individual signals do not prove a journey started or that the vehicle is moving.", []string{"drive-started", "drive-parked", "drive-reverse", "drive-neutral", "driver-arrived", "driver-left", "driver-belt", "destination-changed"}},
+ {"locations", "Places and routines", "Home, work, saved favorites and destination changes using the vehicle's location flags.", []string{"home-arrived", "home-left", "work-arrived", "work-left", "favorite-arrived", "favorite-left", "destination-changed"}},
+ {"software", "Software watch", "Update availability, progress, schedules and version changes. A stopped update is not proof of success.", []string{"update-available", "update-started", "update-ended", "update-version", "update-schedule", "software-version"}},
+ {"media", "Soundtrack companion", "Track, artist, station, source and playback changes. May be chatty; review cooldowns before enabling.", []string{"media-track", "media-artist", "media-playback", "media-source", "media-station"}},
+ {"powershare", "Powershare watch", "Power-sharing state, type and stop reasons, with battery-reserve reminders. Requires a vehicle that supports Powershare.", []string{"powershare-state", "powershare-stop", "powershare-type", "battery-low", "battery-critical", "battery-reserve"}},
+ {"winter", "Cold-weather readiness", "Cold cabin and battery, preconditioning, heating and charge-port cold-weather mode. Not a road-ice or occupant-safety detector.", []string{"cabin-cold", "outside-freezing", "module-cold", "battery-heating", "battery-heating-ended", "preconditioning", "preconditioning-ended", "charge-cold-weather"}},
+ {"handover", "Shared vehicle handover", "Lock, PIN, valet, guest mode and driver-presence changes. Review access settings yourself; no commands are issued.", []string{"locked", "unlocked", "pin-enabled", "pin-disabled", "valet-enabled", "valet-disabled", "guest-enabled", "guest-disabled", "driver-arrived", "driver-left"}},
+ }
+ for _, group := range groups {
+ pack := Pack{ID: group.id, Version: 2, Name: group.name, Description: group.description}
+ for _, id := range group.ids {
+ pack.Rules = append(pack.Rules, byID[id])
+ }
+ packs = append(packs, pack)
+ }
+ all := Pack{ID: "all", Version: 2, Name: "All alerts", Description: "Every unique rule from every supported pack in one installation. Review the selection: overlapping thresholds and frequent state changes can be noisy. Vehicle support and telemetry availability vary."}
+ seen := map[string]bool{}
+ for _, pack := range packs {
+ for _, rule := range pack.Rules {
+ if !seen[rule.ID] {
+ all.Rules = append(all.Rules, rule)
+ seen[rule.ID] = true
+ }
+ }
+ }
+ return append([]Pack{all}, packs...)
+}
diff --git a/internal/api/ai_routes.go b/internal/api/ai_routes.go
index bf0a7c34a8..05f7fd8033 100644
--- a/internal/api/ai_routes.go
+++ b/internal/api/ai_routes.go
@@ -23,12 +23,15 @@ package api
import (
"net/http"
+ "time"
"github.com/ev-dev-labs/teslasync/internal/ai/dispatch"
"github.com/ev-dev-labs/teslasync/internal/ai/guard"
"github.com/ev-dev-labs/teslasync/internal/ai/provider"
+ "github.com/ev-dev-labs/teslasync/internal/api/httpx"
settingsdb "github.com/ev-dev-labs/teslasync/internal/database/settings"
"github.com/go-chi/chi/v5"
+ "github.com/go-chi/httprate"
"github.com/rs/zerolog/log"
)
@@ -67,6 +70,7 @@ type AIHandlers struct {
Anomaly http.Handler
Alert http.Handler
AlertMessageTemplate http.Handler
+ AlertPacks http.Handler
Automation http.Handler
Search http.Handler
DriveCoach http.Handler
@@ -928,6 +932,13 @@ func mountAIRoutes(
alertMessageTemplateHandler = h.AlertMessageTemplate.ServeHTTP
}
r.Post("/alerts/message-template/draft", g.Wrap("alert-message-template-suggestion", alertMessageTemplateHandler))
+ r.With(httprate.LimitByIP(10, time.Minute)).Post("/alerts/packs/draft", g.Wrap("alert-pack-builder", func(w http.ResponseWriter, r *http.Request) {
+ if h.AlertPacks == nil {
+ httpx.WriteError(w, http.StatusServiceUnavailable, "alert pack advisor unavailable")
+ return
+ }
+ h.AlertPacks.ServeHTTP(w, r)
+ }))
// inbox-auto-categorization (Phase-50 / A2, slice 0035).
// Opt-in LLM that reads recent notification_log rows
diff --git a/internal/api/aialertpacks/doc.go b/internal/api/aialertpacks/doc.go
new file mode 100644
index 0000000000..ef53965259
--- /dev/null
+++ b/internal/api/aialertpacks/doc.go
@@ -0,0 +1,4 @@
+// Package aialertpacks serves the propose-only Helix custom Alert Pack endpoint.
+//
+// Layer: handler
+package aialertpacks
diff --git a/internal/api/aialertpacks/handler.go b/internal/api/aialertpacks/handler.go
new file mode 100644
index 0000000000..20b735528d
--- /dev/null
+++ b/internal/api/aialertpacks/handler.go
@@ -0,0 +1,82 @@
+package aialertpacks
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/ev-dev-labs/teslasync/internal/ai/dispatch"
+ "github.com/ev-dev-labs/teslasync/internal/ai/provider"
+ alertpackbuilder "github.com/ev-dev-labs/teslasync/internal/ai/strategies/alert-pack-builder"
+ "github.com/ev-dev-labs/teslasync/internal/ai/strategy"
+ "github.com/ev-dev-labs/teslasync/internal/ai/stream"
+ "github.com/ev-dev-labs/teslasync/internal/ai/tools"
+ "github.com/ev-dev-labs/teslasync/internal/api/httpx"
+ tsauth "github.com/ev-dev-labs/teslasync/internal/auth"
+ "github.com/rs/zerolog/log"
+ "go.opentelemetry.io/otel"
+)
+
+type Handler struct {
+ registry *provider.Registry
+ tools *tools.Registry
+ header string
+}
+
+func NewHandler(registry *provider.Registry, toolRegistry *tools.Registry, header string) *Handler {
+ return &Handler{registry: registry, tools: toolRegistry, header: header}
+}
+
+func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ ctx, span := otel.Tracer("api").Start(r.Context(), "api.ai.alert_packs.draft")
+ defer span.End()
+ ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
+ defer cancel()
+ r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
+ var body struct {
+ Goal string `json:"goal"`
+ }
+ decoder := json.NewDecoder(r.Body)
+ decoder.DisallowUnknownFields()
+ err := decoder.Decode(&body)
+ if err == nil {
+ if trailingErr := decoder.Decode(new(any)); !errors.Is(trailingErr, io.EOF) {
+ err = errors.New("request must contain one JSON object")
+ }
+ }
+ if err != nil || len([]rune(strings.TrimSpace(body.Goal))) < 5 || len([]rune(body.Goal)) > 2000 {
+ if err != nil {
+ span.RecordError(err)
+ }
+ httpx.WriteError(w, http.StatusBadRequest, "goal must contain 5 to 2000 characters")
+ return
+ }
+ subject, _ := tsauth.SubjectFromRequest(r, h.header)
+ ctx = provider.WithSubject(ctx, subject)
+ ctx = provider.WithFeatureID(ctx, alertpackbuilder.FeatureID)
+ prov, err := h.registry.For(ctx, alertpackbuilder.FeatureID)
+ if err != nil {
+ span.RecordError(err)
+ log.Error().Err(err).Str("trace_id", span.SpanContext().TraceID().String()).Msg("alert pack AI provider unavailable")
+ httpx.WriteError(w, http.StatusBadGateway, "AI provider unavailable")
+ return
+ }
+ writer, ctx, err := stream.New(ctx, w, stream.WithFeatureID(alertpackbuilder.FeatureID))
+ if err != nil {
+ span.RecordError(err)
+ httpx.WriteError(w, http.StatusInternalServerError, "streaming unavailable")
+ return
+ }
+ deny := func(context.Context, dispatch.ConfirmRequest) (dispatch.ConfirmDecision, error) {
+ return dispatch.ConfirmDenied, nil
+ }
+ dispatcher := dispatch.New(h.tools, prov, deny, 4)
+ if err := dispatcher.Run(ctx, alertpackbuilder.New(), strategy.StrategyInput{LastMessage: body.Goal}, writer); err != nil {
+ span.RecordError(err)
+ log.Error().Err(err).Str("trace_id", span.SpanContext().TraceID().String()).Msg("alert pack proposal failed")
+ }
+}
diff --git a/internal/api/aialertpacks/handler_test.go b/internal/api/aialertpacks/handler_test.go
new file mode 100644
index 0000000000..9d3460b3da
--- /dev/null
+++ b/internal/api/aialertpacks/handler_test.go
@@ -0,0 +1,101 @@
+package aialertpacks
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/ev-dev-labs/teslasync/internal/ai/guard"
+ "github.com/ev-dev-labs/teslasync/internal/ai/provider"
+ "github.com/ev-dev-labs/teslasync/internal/ai/tools"
+ alerttools "github.com/ev-dev-labs/teslasync/internal/ai/tools/alert"
+)
+
+type settings struct {
+ mode string
+ enabled bool
+}
+
+func (s settings) AIMode(context.Context) (string, error) { return s.mode, nil }
+func (s settings) AIFeatureEnabled(context.Context, string) (bool, error) { return s.enabled, nil }
+func (s settings) AIProviderConfig(context.Context) (map[string]any, error) { return nil, nil }
+
+type proposalProvider struct {
+ calls int
+ invalid bool
+}
+
+func (*proposalProvider) Name() string { return "ollama" }
+func (*proposalProvider) Capabilities() provider.Capabilities {
+ return provider.Capabilities{Tools: true}
+}
+func (*proposalProvider) Stream(context.Context, provider.ChatRequest) (<-chan provider.Chunk, error) {
+ return nil, provider.ErrCapabilityNotSupported
+}
+func (*proposalProvider) Embed(context.Context, provider.EmbedRequest) (*provider.EmbedResponse, error) {
+ return nil, provider.ErrCapabilityNotSupported
+}
+func (p *proposalProvider) Chat(_ context.Context, req provider.ChatRequest) (*provider.ChatResponse, error) {
+ p.calls++
+ if p.calls == 1 {
+ id := "charge-complete"
+ if p.invalid {
+ id = "invented"
+ }
+ return &provider.ChatResponse{FinishReason: provider.FinishToolCalls, ToolCalls: []provider.ToolCall{{
+ ID: "proposal-1", Name: "propose_alert_pack",
+ Arguments: []byte(`{"name":"Weekend","template_ids":["battery-low","` + id + `"],"rationale":"Low-noise reminders."}`),
+ }}}, nil
+ }
+ return &provider.ChatResponse{FinishReason: provider.FinishStop, Message: provider.Message{Role: provider.RoleAssistant, Content: "Review the proposed group before installing."}}, nil
+}
+
+func TestProposalDispatchRoundTrip(t *testing.T) {
+ for _, invalid := range []bool{false, true} {
+ p := &proposalProvider{invalid: invalid}
+ registry := provider.NewRegistry(settings{"local", true})
+ registry.Register("ollama", func(provider.ProviderConfig) (provider.Provider, error) { return p, nil })
+ toolRegistry := tools.NewRegistry()
+ alerttools.RegisterAlertPackTools(toolRegistry)
+ rec := httptest.NewRecorder()
+ NewHandler(registry, toolRegistry, "").ServeHTTP(rec, httptest.NewRequest("POST", "/", strings.NewReader(`{"goal":"battery and charging reminders"}`)))
+ if rec.Code != 200 || p.calls != 2 || !strings.Contains(rec.Header().Get("Content-Type"), "text/event-stream") {
+ t.Fatalf("status=%d calls=%d body=%s", rec.Code, p.calls, rec.Body.String())
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, `"name":"propose_alert_pack"`) {
+ t.Fatal("missing typed proposal event")
+ }
+ if invalid && strings.Contains(body, `"status":"ok"`) {
+ t.Fatal("invented rule returned a valid proposal")
+ }
+ if !invalid && !strings.Contains(body, `"status":"ok"`) {
+ t.Fatalf("valid proposal missing: %s", body)
+ }
+ }
+}
+
+func TestGuardPreventsProviderAccess(t *testing.T) {
+ for _, s := range []settings{{"off", true}, {"cloud", false}} {
+ rec := httptest.NewRecorder()
+ handler := NewHandler(nil, nil, "")
+ guard.New(s).Wrap("alert-pack-builder", handler.ServeHTTP)(rec, httptest.NewRequest("POST", "/", strings.NewReader(`{"goal":"battery reminders"}`)))
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("AI disabled returned %d", rec.Code)
+ }
+ }
+}
+
+func TestInvalidGoalRejectedBeforeProvider(t *testing.T) {
+ h := NewHandler(nil, nil, "")
+ for _, body := range []string{`{}`, `{`, `{"goal":" "}`, `{"goal":"tiny"}`, `{"goal":"` + strings.Repeat("a", 2001) + `"}`,
+ `{"goal":"charge reminders","install":true}`, `{"goal":"charge reminders"} {}`, `{"goal":"charge reminders"}` + strings.Repeat(" ", 16*1024)} {
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest("POST", "/", strings.NewReader(body)))
+ if rec.Code != 400 {
+ t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
+ }
+ }
+}
diff --git a/internal/api/aisettingsvalidate/azure_test.go b/internal/api/aisettingsvalidate/azure_test.go
new file mode 100644
index 0000000000..793d252e9b
--- /dev/null
+++ b/internal/api/aisettingsvalidate/azure_test.go
@@ -0,0 +1,134 @@
+package aisettingsvalidate
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/ev-dev-labs/teslasync/internal/ai/provider"
+ "github.com/ev-dev-labs/teslasync/internal/ai/provider/azure"
+)
+
+func TestAzureNegotiationErrorPreservesBothFailures(t *testing.T) {
+ chatErr := fmt.Errorf("%w: azure chat status 404: DeploymentNotFound", provider.ErrUpstream)
+ responsesErr := fmt.Errorf("%w: azure responses status 401: invalid key", provider.ErrUpstream)
+ code, message := classifyCloudProbeError(context.Background(), errors.Join(chatErr, responsesErr))
+ if code != validateConfigCodeUnauthorized || !strings.Contains(message, chatErr.Error()) ||
+ !strings.Contains(message, responsesErr.Error()) {
+ t.Fatalf("code=%s message=%s", code, message)
+ }
+}
+
+func TestAzureValidationAndHelixUseSameIdentity(t *testing.T) {
+ for _, tc := range []struct {
+ name, protocol, override, want string
+ }{
+ {"auto", "auto", "", "visible-model"},
+ {"chat", "chat_completions", "", "visible-model"},
+ {"responses", "responses", "", "visible-model"},
+ {"edited_identity", "responses", `,"model":"edited-deployment"`, "edited-deployment"},
+ {"edited_protocol", "chat_completions", `,"api_protocol":"responses"`, "visible-model"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ var mu sync.Mutex
+ var identities []string
+ var budgets []int
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Model string `json:"model"`
+ MaxTokens int `json:"max_tokens"`
+ MaxCompletionTokens int `json:"max_completion_tokens"`
+ MaxOutputTokens int `json:"max_output_tokens"`
+ Stream bool `json:"stream"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ t.Error(err)
+ }
+ wantPath := "/openai/v1/chat/completions"
+ if tc.protocol == "responses" || tc.name == "edited_protocol" {
+ wantPath = "/openai/v1/responses"
+ }
+ if r.URL.Path != wantPath || r.URL.RawQuery != "" {
+ t.Errorf("path=%s want=%s", r.URL.String(), wantPath)
+ }
+ identity := body.Model
+ cap := body.MaxTokens + body.MaxCompletionTokens + body.MaxOutputTokens
+ mu.Lock()
+ identities = append(identities, identity)
+ budgets = append(budgets, cap)
+ mu.Unlock()
+ if wantPath == "/openai/v1/responses" {
+ _, _ = io.WriteString(w, `{"status":"completed","output_text":"OK"}`)
+ } else if body.Stream {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = io.WriteString(w, "data: "+`{"choices":[{"delta":{"content":"OK"},"finish_reason":"stop"}]}`+"\n\ndata: [DONE]\n\n")
+ } else {
+ _, _ = io.WriteString(w, `{"choices":[{"message":{"role":"assistant","content":"OK"},"finish_reason":"stop"}]}`)
+ }
+ }))
+ defer srv.Close()
+ saved := map[string]any{
+ "default": "azure",
+ "azure": map[string]any{
+ "base_url": srv.URL + "/openai/v1", "api_key": "k", "model": "visible-model",
+ "api_protocol": tc.protocol,
+ },
+ }
+ var live provider.Provider
+ h := newTestValidateHandler(saved, "azure", func(cfg provider.ProviderConfig) (provider.Provider, error) {
+ a, err := azure.New(cfg, azure.WithHTTPClient(srv.Client()))
+ live = a
+ return a, err
+ })
+ rec := httptest.NewRecorder()
+ h(rec, httptest.NewRequest(http.MethodPost, "/api/v1/settings/ai/validate-config",
+ bytes.NewBufferString(`{"mode":"cloud","provider":"azure"`+tc.override+`}`)))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("validation: %d %s", rec.Code, rec.Body.String())
+ }
+ var result validateConfigResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
+ t.Fatal(err)
+ }
+ if result.ProbedModel != tc.want {
+ t.Fatalf("reported=%s want=%s", result.ProbedModel, tc.want)
+ }
+ req := provider.ChatRequest{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hello"}}, MaxTokens: 17}
+ if _, err := live.Chat(context.Background(), req); err != nil {
+ t.Fatal(err)
+ }
+ ch, err := live.Stream(context.Background(), req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var done int
+ for c := range ch {
+ if c.Err != nil {
+ t.Fatal(c.Err)
+ }
+ if c.Done {
+ done++
+ }
+ }
+ if done != 1 {
+ t.Fatalf("terminal count=%d", done)
+ }
+ mu.Lock()
+ defer mu.Unlock()
+ if len(identities) != 3 || identities[0] != tc.want || identities[1] != tc.want || identities[2] != tc.want {
+ t.Fatalf("validation/Chat/Stream identities=%v want=%s", identities, tc.want)
+ }
+ if budgets[0] != validateConfigAzureProbeTokens || budgets[1] != 17 || budgets[2] != 17 {
+ t.Fatalf("validation/Chat/Stream budgets=%v", budgets)
+ }
+ })
+ }
+}
diff --git a/internal/api/aisettingsvalidate/handler.go b/internal/api/aisettingsvalidate/handler.go
index 857e474145..a6464a1d71 100644
--- a/internal/api/aisettingsvalidate/handler.go
+++ b/internal/api/aisettingsvalidate/handler.go
@@ -7,7 +7,7 @@ package aisettingsvalidate
// AI routes are guard-wrapped (ADR-015 §I6).
//
// Local mode only resolves DNS and enforces local-address rules. Cloud mode
-// performs a one-token probe because syntax checks cannot validate provider,
+// performs a bounded probe because syntax checks cannot validate provider,
// endpoint, deployment, API version, and key alignment; saved API keys may be
// reused for the probe and are never logged.
@@ -57,13 +57,10 @@ type validateConfigRequest struct {
// saved semantics. APIKey in particular falls back to the
// previously-saved key so the user can validate after editing
// a non-secret field without re-typing the secret.
- APIKey string `json:"api_key,omitempty"`
- Model string `json:"model,omitempty"`
- APIVersion string `json:"api_version,omitempty"`
- Flavor string `json:"flavor,omitempty"`
- Deployment string `json:"deployment,omitempty"`
- EmbeddingModel string `json:"embedding_model,omitempty"`
- EmbeddingDeployment string `json:"embedding_deployment,omitempty"`
+ APIKey string `json:"api_key,omitempty"`
+ Model string `json:"model,omitempty"`
+ APIProtocol string `json:"api_protocol,omitempty"`
+ EmbeddingModel string `json:"embedding_model,omitempty"`
}
// validateConfigResponse is the JSON body of a successful 200.
@@ -112,6 +109,10 @@ const validateConfigLocalTimeout = 5 * time.Second
// surfaces "validating…" so the user knows work is in flight.
const validateConfigCloudTimeout = 30 * time.Second
+// Includes reasoning tokens; this is a validation budget, not an override of
+// a caller's Chat/Stream token cap.
+const validateConfigAzureProbeTokens = 1024
+
// httpStatusInErrorRe extracts an HTTP status code from a wrapped
// adapter error message (the adapters embed the status in the wrap
// text, e.g. "openai chat status 401: Unauthorized"). We classify
@@ -235,21 +236,18 @@ func handleValidateCloud(
savedCfg, _ := provider.ParseProviderConfig(rawCfg, name)
cfg := provider.ProviderConfig{
- BaseURL: firstNonEmpty(req.BaseURL, savedCfg.BaseURL),
- Model: firstNonEmpty(req.Model, savedCfg.Model),
- EmbeddingModel: firstNonEmpty(req.EmbeddingModel, savedCfg.EmbeddingModel),
- APIKey: firstNonEmpty(req.APIKey, savedCfg.APIKey),
- APIVersion: firstNonEmpty(req.APIVersion, savedCfg.APIVersion),
- Flavor: firstNonEmpty(req.Flavor, savedCfg.Flavor),
- Deployment: firstNonEmpty(req.Deployment, savedCfg.Deployment),
- EmbeddingDeployment: firstNonEmpty(req.EmbeddingDeployment, savedCfg.EmbeddingDeployment),
+ BaseURL: firstNonEmpty(req.BaseURL, savedCfg.BaseURL),
+ Model: firstNonEmpty(req.Model, savedCfg.Model),
+ EmbeddingModel: firstNonEmpty(req.EmbeddingModel, savedCfg.EmbeddingModel),
+ APIKey: firstNonEmpty(req.APIKey, savedCfg.APIKey),
+ APIProtocol: firstNonEmpty(req.APIProtocol, savedCfg.APIProtocol),
}
// Cheap pre-flight checks so the SPA can render a precise
// "you forgot the API key" message instead of an opaque
// adapter error. The order matters: api_key is the most
// likely missing field, then base_url for Azure, then
- // deployment for Azure OpenAI Service flavor.
+ // deployment name for Foundry.
if cfg.APIKey == "" {
httpx.WriteErrorCode(w, http.StatusUnprocessableEntity,
"api key is required for cloud validation",
@@ -262,18 +260,11 @@ func handleValidateCloud(
validateConfigCodeMissingBaseURL)
return
}
- if name == provider.NameAzure {
- flavor := cfg.Flavor
- if flavor == "" {
- flavor = provider.DefaultAzureFlavor
- }
- if flavor == provider.AzureFlavorOpenAI &&
- cfg.Deployment == "" && cfg.Model == "" {
- httpx.WriteErrorCode(w, http.StatusUnprocessableEntity,
- "deployment name (or model) is required for Azure OpenAI Service",
- validateConfigCodeMissingDeployment)
- return
- }
+ if name == provider.NameAzure && cfg.Model == "" {
+ httpx.WriteErrorCode(w, http.StatusUnprocessableEntity,
+ "deployment name is required for Microsoft Foundry",
+ validateConfigCodeMissingDeployment)
+ return
}
prov, err := registry.ProviderForName(name, cfg)
@@ -290,19 +281,19 @@ func handleValidateCloud(
return
}
- // One-shot probe. MaxTokens=1 keeps cost negligible (~$0.0001
- // for gpt-4o-mini). "ping" is short enough that the model
- // almost always emits a single token without the conversation
- // derailing into long-form output.
+ // Leave Model unset, as Helix does, so adapter deployment precedence is
+ // identical during validation and actual use.
probeReq := provider.ChatRequest{
- Model: cfg.Model,
Messages: []provider.Message{
- {Role: "user", Content: "ping"},
+ {Role: "user", Content: "Reply only with OK."},
},
MaxTokens: 1,
Temperature: 0,
}
- resp, err := prov.Chat(ctx, probeReq)
+ if name == provider.NameAzure {
+ probeReq.MaxTokens = validateConfigAzureProbeTokens
+ }
+ _, err = prov.Chat(ctx, probeReq)
if err != nil {
code, msg := classifyCloudProbeError(ctx, err)
log.Info().
@@ -315,12 +306,6 @@ func handleValidateCloud(
}
probedModel := cfg.Model
- if resp != nil && resp.Message.Content != "" {
- // Some providers echo the model identifier in the response;
- // we keep the configured one for stability since the
- // response shape is provider-specific.
- _ = resp
- }
httpx.WriteJSON(w, http.StatusOK, validateConfigResponse{
OK: true,
@@ -336,6 +321,15 @@ func handleValidateCloud(
// sentinel chain (errors.Is), (2) the embedded HTTP status from the
// adapter's error message, (3) ctx cancellation, in that order.
func classifyCloudProbeError(ctx context.Context, err error) (code, message string) {
+ // Protocol negotiation retains both failures. Classify the final operation,
+ // but do not hide the first error from the validation result.
+ if joined, ok := err.(interface{ Unwrap() []error }); ok {
+ failures := joined.Unwrap()
+ if len(failures) > 0 {
+ code, _ := classifyCloudProbeError(ctx, failures[len(failures)-1])
+ return code, err.Error()
+ }
+ }
if errors.Is(err, provider.ErrCapabilityNotSupported) {
return validateConfigCodeInvalid, err.Error()
}
diff --git a/internal/api/aisettingsvalidate/handler_test.go b/internal/api/aisettingsvalidate/handler_test.go
index cf91f490f2..dfab35ab50 100644
--- a/internal/api/aisettingsvalidate/handler_test.go
+++ b/internal/api/aisettingsvalidate/handler_test.go
@@ -355,7 +355,7 @@ func TestHandler_Cloud_Probe404_NotFound(t *testing.T) {
}, nil
}
h := newTestValidateHandler(nil, "azure", build)
- body := bytes.NewBufferString(`{"mode":"cloud","provider":"azure","flavor":"openai","base_url":"https://r.openai.azure.com","model":"gpt-4o","deployment":"missing","api_key":"k"}`)
+ body := bytes.NewBufferString(`{"mode":"cloud","provider":"azure","base_url":"https://r.services.ai.azure.com/openai/v1","model":"missing","api_protocol":"auto","api_key":"k"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/settings/ai/validate-config", body)
rec := httptest.NewRecorder()
h(rec, req)
@@ -424,13 +424,13 @@ func TestHandler_Cloud_UnknownProvider_Rejected(t *testing.T) {
}
func TestHandler_Cloud_AzureMissingDeployment_Rejected(t *testing.T) {
- // Azure OpenAI Service flavor needs deployment OR model — when
+ // Foundry requires a deployment name in the model field — when
// both are empty the handler short-circuits with
// missing_deployment so the SPA can render a precise message.
h := newTestValidateHandler(nil, "azure", func(_ provider.ProviderConfig) (provider.Provider, error) {
return &fakeProvider{name: "azure"}, nil
})
- body := bytes.NewBufferString(`{"mode":"cloud","provider":"azure","flavor":"openai","base_url":"https://r.openai.azure.com","api_key":"k"}`)
+ body := bytes.NewBufferString(`{"mode":"cloud","provider":"azure","base_url":"https://r.services.ai.azure.com/openai/v1","api_key":"k"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/settings/ai/validate-config", body)
rec := httptest.NewRecorder()
h(rec, req)
diff --git a/internal/api/alerts/alert.go b/internal/api/alerts/alert.go
index fa56786ad2..486cb815c0 100644
--- a/internal/api/alerts/alert.go
+++ b/internal/api/alerts/alert.go
@@ -28,6 +28,7 @@ type AlertHandler struct {
db *database.DB
alertRuleRepo alertRuleRepository
bulkRuleRepo alertRuleBulkRepository
+ packRepo packRepository
notifRepo notificationRepository
eventHub EventBroadcaster
mqttClient pahomqtt.Client
@@ -74,6 +75,7 @@ func NewAlertHandler(db *database.DB, hub EventBroadcaster, mc pahomqtt.Client,
db: db,
alertRuleRepo: repo,
bulkRuleRepo: repo,
+ packRepo: repo,
notifRepo: dbnotif.NewNotificationRepo(db),
eventHub: hub,
mqttClient: mc,
diff --git a/internal/api/alerts/alert_dtos.go b/internal/api/alerts/alert_dtos.go
index 38149897fb..f0bbea5897 100644
--- a/internal/api/alerts/alert_dtos.go
+++ b/internal/api/alerts/alert_dtos.go
@@ -3,6 +3,7 @@ package alerts
import "time"
type createAlertRuleRequest struct {
+ ChannelIDs []int64 `json:"channel_ids"`
Name *string `json:"name"`
Description *string `json:"description"`
Enabled *bool `json:"enabled"`
@@ -60,6 +61,7 @@ type createAlertRuleRequest struct {
}
type updateAlertRuleRequest struct {
+ ChannelIDs []int64 `json:"channel_ids"`
Name *string `json:"name"`
Description *string `json:"description"`
Enabled *bool `json:"enabled"`
diff --git a/internal/api/alerts/alert_rules.go b/internal/api/alerts/alert_rules.go
index 62dc640c05..f620686f37 100644
--- a/internal/api/alerts/alert_rules.go
+++ b/internal/api/alerts/alert_rules.go
@@ -206,6 +206,12 @@ func (h *AlertHandler) UpdateRule(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "rule not found")
return
}
+ if fieldPresent(fields, "channel_ids") {
+ if !h.checkRuleChannels(w, r, body.ChannelIDs) {
+ return
+ }
+ existing.ChannelIDs = body.ChannelIDs
+ }
if fieldPresent(fields, "name") {
if body.Name == nil {
@@ -378,6 +384,9 @@ func (h *AlertHandler) CreateRule(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "enabled must be a boolean")
return
}
+ if !h.checkRuleChannels(w, r, body.ChannelIDs) {
+ return
+ }
if fieldPresent(fields, "severity") && body.Severity == nil {
writeError(w, http.StatusBadRequest, "severity must be info, warn, or critical")
return
@@ -426,6 +435,7 @@ func (h *AlertHandler) CreateRule(w http.ResponseWriter, r *http.Request) {
}
rule := &alertmodel.AlertRule{
+ ChannelIDs: body.ChannelIDs,
Name: name,
Description: body.Description,
Enabled: enabled,
diff --git a/internal/api/alerts/bulk.go b/internal/api/alerts/bulk.go
index a1f87e7ec3..934428bc07 100644
--- a/internal/api/alerts/bulk.go
+++ b/internal/api/alerts/bulk.go
@@ -1,13 +1,63 @@
package alerts
import (
+ "context"
"fmt"
"net/http"
+ "time"
"github.com/ev-dev-labs/teslasync/internal/api/apibulk"
"github.com/rs/zerolog/log"
+ "go.opentelemetry.io/otel"
)
+func (h *AlertHandler) BulkDeleteRules(w http.ResponseWriter, r *http.Request) {
+ ctx, span := otel.Tracer("api").Start(r.Context(), "api.alerts.rules.bulk_delete")
+ defer span.End()
+ ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
+ defer cancel()
+ repo, ok := h.bulkRuleRepo.(interface {
+ BulkDelete(context.Context, []int64) ([]int64, error)
+ })
+ if !ok {
+ writeError(w, http.StatusServiceUnavailable, "bulk deletion not configured")
+ return
+ }
+ var body apibulk.IDsBody
+ _, err := decodeStrictAlertRequest(r, &body, nil)
+ if err != nil {
+ span.RecordError(err)
+ apibulk.WriteBadRequest(w, err)
+ return
+ }
+ if len(body.IDs) == 0 || len(body.IDs) > apibulk.MaxIDs {
+ writeError(w, http.StatusBadRequest, "select between 1 and 500 rule IDs")
+ return
+ }
+ for _, id := range body.IDs {
+ if id <= 0 {
+ writeError(w, http.StatusBadRequest, "rule IDs must be positive")
+ return
+ }
+ }
+ ids := apibulk.DedupeInt64s(body.IDs)
+ deleted, err := repo.BulkDelete(ctx, ids)
+ if err != nil {
+ span.RecordError(err)
+ log.Error().Err(err).Str("trace_id", span.SpanContext().TraceID().String()).Msg("bulk delete alert rules failed")
+ writeError(w, http.StatusInternalServerError, "failed to delete alert rules")
+ return
+ }
+ if h.db != nil {
+ logAuditFromRequest(h.db, r, h.forwardAuthHeader, "bulk_delete", "alert_rule", nil,
+ fmt.Sprintf("requested=%d deleted=%d", len(ids), len(deleted)))
+ }
+ log.Info().Str("trace_id", span.SpanContext().TraceID().String()).Int("deleted", len(deleted)).Msg("alert rules deleted")
+ writeJSON(w, http.StatusOK, struct {
+ DeletedIDs []int64 `json:"deleted_ids"`
+ }{deleted})
+}
+
// BulkEnableRules sets enabled=TRUE for every rule in the request body's
// `ids` array.
func (h *AlertHandler) BulkEnableRules(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/api/alerts/bulk_test.go b/internal/api/alerts/bulk_test.go
index da974a1594..d31d4d1579 100644
--- a/internal/api/alerts/bulk_test.go
+++ b/internal/api/alerts/bulk_test.go
@@ -19,6 +19,54 @@ type fakeAlertRuleBulkRepo struct {
setEnabledTo bool
}
+func (f *fakeAlertRuleBulkRepo) BulkDelete(_ context.Context, ids []int64) ([]int64, error) {
+ if f.updateErr != nil {
+ return nil, f.updateErr
+ }
+ f.setEnabledArg = append([]int64{}, ids...)
+ deleted := []int64{}
+ for _, id := range ids {
+ if f.existing[id] {
+ deleted = append(deleted, id)
+ }
+ }
+ return deleted, nil
+}
+
+func TestBulkRuleDelete(t *testing.T) {
+ for _, tt := range []struct {
+ body string
+ status int
+ wantCalls bool
+ }{
+ {`{"ids":[1,2,2,99]}`, 200, true},
+ {`{"ids":[]}`, 400, false},
+ {`{"ids":[0]}`, 400, false},
+ {`{"ids":[-1]}`, 400, false},
+ {`{"ids":[1],"unknown":true}`, 400, false},
+ {`{"ids":[1]} {"ids":[2]}`, 400, false},
+ } {
+ repo := &fakeAlertRuleBulkRepo{existing: map[int64]bool{1: true, 2: true}}
+ h := &AlertHandler{bulkRuleRepo: repo}
+ rec := httptest.NewRecorder()
+ h.BulkDeleteRules(rec, newBulkRequest(t, "POST", "/alerts/rules/bulk/delete", tt.body))
+ if rec.Code != tt.status || (len(repo.setEnabledArg) > 0) != tt.wantCalls {
+ t.Fatalf("body=%s status=%d result=%s", tt.body, rec.Code, rec.Body.String())
+ }
+ if tt.wantCalls {
+ var result struct {
+ DeletedIDs []int64 `json:"deleted_ids"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
+ t.Fatal(err)
+ }
+ if len(result.DeletedIDs) != 2 || len(repo.setEnabledArg) != 3 {
+ t.Fatal("deletion did not deduplicate or accurately report affected rows")
+ }
+ }
+ }
+}
+
func (f *fakeAlertRuleBulkRepo) FilterExistingIDs(_ context.Context, ids []int64) ([]int64, error) {
out := make([]int64, 0, len(ids))
for _, id := range ids {
diff --git a/internal/api/alerts/packs.go b/internal/api/alerts/packs.go
new file mode 100644
index 0000000000..0369f16519
--- /dev/null
+++ b/internal/api/alerts/packs.go
@@ -0,0 +1,147 @@
+package alerts
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "time"
+
+ "github.com/ev-dev-labs/teslasync/internal/alertpacks"
+ "github.com/go-chi/chi/v5"
+ "github.com/jackc/pgx/v5/pgconn"
+ "github.com/rs/zerolog/log"
+ "go.opentelemetry.io/otel"
+ "go.opentelemetry.io/otel/trace"
+)
+
+type packRepository interface {
+ ListPackInstallations(context.Context, int, int) ([]alertpacks.Installation, error)
+ InstallPack(context.Context, alertpacks.Pack, string, []alertpacks.Template) (*alertpacks.Installation, error)
+ RemovePack(context.Context, int64, []int64) error
+}
+
+func (h *AlertHandler) ListPacks(w http.ResponseWriter, r *http.Request) {
+ _, span := otel.Tracer("api").Start(r.Context(), "api.alerts.packs.catalog")
+ defer span.End()
+ writeJSON(w, http.StatusOK, append(alertpacks.Catalog(), alertpacks.CustomCatalog()))
+}
+
+func (h *AlertHandler) ListPackInstallations(w http.ResponseWriter, r *http.Request) {
+ ctx, span := otel.Tracer("api").Start(r.Context(), "api.alerts.packs.list")
+ defer span.End()
+ limit, offset := pagination(r)
+ items, err := h.packRepo.ListPackInstallations(ctx, limit, offset)
+ if err != nil {
+ packError(w, span, err)
+ return
+ }
+ if items == nil {
+ items = []alertpacks.Installation{}
+ }
+ writeJSON(w, http.StatusOK, items)
+}
+
+func (h *AlertHandler) InstallPack(w http.ResponseWriter, r *http.Request) {
+ ctx, span := otel.Tracer("api").Start(r.Context(), "api.alerts.packs.install")
+ defer span.End()
+ ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
+ defer cancel()
+ pack, found := alertpacks.Find(chi.URLParam(r, "packID"))
+ if !found {
+ writeError(w, http.StatusNotFound, "alert pack not found")
+ return
+ }
+ var req alertpacks.InstallRequest
+ if _, err := decodeStrictAlertRequest(r, &req, nil); err != nil {
+ span.RecordError(err)
+ writeError(w, http.StatusBadRequest, "invalid pack installation request: "+err.Error())
+ return
+ }
+ templates, scope, err := alertpacks.Prepare(pack, req)
+ if err != nil {
+ span.RecordError(err)
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ channelSets := make([][]int64, 0, len(templates))
+ for _, t := range templates {
+ if err := validateAlertRule(&t.Rule); err != nil {
+ span.RecordError(err)
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ channelSets = append(channelSets, t.Rule.ChannelIDs)
+ }
+ if !h.checkRuleChannelSets(w, r.WithContext(ctx), channelSets...) {
+ return
+ }
+ if pack.ID == "custom" {
+ pack, err = alertpacks.NameCustom(pack, req.Name, templates)
+ if err != nil {
+ span.RecordError(err)
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ }
+ result, err := h.packRepo.InstallPack(ctx, pack, scope, templates)
+ if err != nil {
+ packError(w, span, err)
+ return
+ }
+ log.Info().Str("trace_id", span.SpanContext().TraceID().String()).Str("pack_id", pack.ID).Int64("installation_id", result.ID).Msg("alert pack installed")
+ writeJSON(w, http.StatusCreated, result)
+}
+
+func (h *AlertHandler) RemovePack(w http.ResponseWriter, r *http.Request) {
+ ctx, span := otel.Tracer("api").Start(r.Context(), "api.alerts.packs.remove")
+ defer span.End()
+ ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
+ defer cancel()
+ id, err := urlParamInt64(r, "installationID")
+ if err != nil || id <= 0 {
+ writeError(w, http.StatusBadRequest, "invalid installation ID")
+ return
+ }
+ var req struct {
+ DeleteRuleIDs []int64 `json:"delete_rule_ids"`
+ }
+ if _, err := decodeStrictAlertRequest(r, &req, nil); err != nil {
+ span.RecordError(err)
+ writeError(w, http.StatusBadRequest, "invalid removal request: "+err.Error())
+ return
+ }
+ if len(req.DeleteRuleIDs) > 100 {
+ writeError(w, http.StatusBadRequest, "too many rules")
+ return
+ }
+ for _, ruleID := range req.DeleteRuleIDs {
+ if ruleID <= 0 {
+ writeError(w, http.StatusBadRequest, "rule IDs must be positive")
+ return
+ }
+ }
+ if err := h.packRepo.RemovePack(ctx, id, req.DeleteRuleIDs); err != nil {
+ packError(w, span, err)
+ return
+ }
+ log.Info().Str("trace_id", span.SpanContext().TraceID().String()).Int64("installation_id", id).Msg("alert pack removed")
+ writeJSON(w, http.StatusOK, map[string]string{"status": "removed"})
+}
+
+func packError(w http.ResponseWriter, span trace.Span, err error) {
+ span.RecordError(err)
+ switch {
+ case errors.Is(err, alertpacks.ErrInstalled), errors.Is(err, alertpacks.ErrSelection):
+ writeError(w, http.StatusConflict, err.Error())
+ case errors.Is(err, alertpacks.ErrNotFound):
+ writeError(w, http.StatusNotFound, "pack installation not found")
+ default:
+ var pgErr *pgconn.PgError
+ if errors.As(err, &pgErr) && pgErr.Code == "23503" {
+ writeError(w, http.StatusBadRequest, "a selected vehicle no longer exists; refresh and try again")
+ return
+ }
+ log.Error().Err(err).Str("trace_id", span.SpanContext().TraceID().String()).Msg("alert pack operation failed")
+ writeError(w, http.StatusInternalServerError, "alert pack operation failed")
+ }
+}
diff --git a/internal/api/alerts/packs_channels_test.go b/internal/api/alerts/packs_channels_test.go
new file mode 100644
index 0000000000..25bb94cb9e
--- /dev/null
+++ b/internal/api/alerts/packs_channels_test.go
@@ -0,0 +1,33 @@
+package alerts
+
+import (
+ "errors"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/go-chi/chi/v5"
+)
+
+func TestPackChannelsUseOneLookupAndFailAtomically(t *testing.T) {
+ for _, lookupErr := range []error{nil, errors.New("private database details")} {
+ repo := &packFake{}
+ channels := &channelsFake{err: lookupErr}
+ h := &AlertHandler{packRepo: repo, notifRepo: channels}
+ router := chi.NewRouter()
+ router.Post("/packs/{packID}/install", h.InstallPack)
+ body := `{"version":2,"all_vehicles":true,"rules":[{"template_id":"battery-low","channel_ids":[2,3]},{"template_id":"charge-complete","channel_ids":[2]}]}`
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, httptest.NewRequest("POST", "/packs/everyday/install", strings.NewReader(body)))
+ if channels.calls != 1 {
+ t.Fatalf("channel lookups=%d, want one per installation", channels.calls)
+ }
+ if lookupErr == nil {
+ if rec.Code != 201 || repo.calls != 1 {
+ t.Fatalf("valid selection rejected: %s", rec.Body.String())
+ }
+ } else if rec.Code != 500 || repo.calls != 0 || strings.Contains(rec.Body.String(), "private") {
+ t.Fatalf("lookup failure was not safely atomic: status=%d calls=%d", rec.Code, repo.calls)
+ }
+ }
+}
diff --git a/internal/api/alerts/packs_test.go b/internal/api/alerts/packs_test.go
new file mode 100644
index 0000000000..97d967f573
--- /dev/null
+++ b/internal/api/alerts/packs_test.go
@@ -0,0 +1,136 @@
+package alerts
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/ev-dev-labs/teslasync/internal/alertpacks"
+ "github.com/go-chi/chi/v5"
+)
+
+type packFake struct {
+ err error
+ calls int
+ templates []alertpacks.Template
+ pack alertpacks.Pack
+}
+
+func (f *packFake) ListPackInstallations(context.Context, int, int) ([]alertpacks.Installation, error) {
+ return nil, f.err
+}
+func (f *packFake) InstallPack(_ context.Context, p alertpacks.Pack, scope string, rules []alertpacks.Template) (*alertpacks.Installation, error) {
+ f.calls++
+ f.templates, f.pack = rules, p
+ return &alertpacks.Installation{ID: 1, PackID: p.ID, ScopeKey: scope, Members: []alertpacks.Member{}}, f.err
+}
+func (f *packFake) RemovePack(context.Context, int64, []int64) error { f.calls++; return f.err }
+
+func packRouter(f *packFake) http.Handler {
+ h := &AlertHandler{packRepo: f, notifRepo: &channelsFake{}}
+ r := chi.NewRouter()
+ r.Get("/packs", h.ListPacks)
+ r.Get("/installations", h.ListPackInstallations)
+ r.Post("/packs/{packID}/install", h.InstallPack)
+ r.Post("/installations/{installationID}/remove", h.RemovePack)
+ return r
+}
+
+func TestPackEndpoints(t *testing.T) {
+ valid := `{"version":2,"all_vehicles":true,"enabled":false,"rules":[{"template_id":"battery-low"}]}`
+ for _, tt := range []struct {
+ name, method, path, body string
+ err error
+ status, calls int
+ }{
+ {"catalog", "GET", "/packs", "", nil, 200, 0},
+ {"empty list", "GET", "/installations", "", nil, 200, 0},
+ {"list failure", "GET", "/installations", "", errors.New("db down"), 500, 0},
+ {"install", "POST", "/packs/everyday/install", valid, nil, 201, 1},
+ {"unknown pack", "POST", "/packs/no/install", valid, nil, 404, 0},
+ {"bad JSON", "POST", "/packs/everyday/install", "{", nil, 400, 0},
+ {"unknown field", "POST", "/packs/everyday/install", `{"inject":true}`, nil, 400, 0},
+ {"empty rules", "POST", "/packs/everyday/install", `{"version":1,"all_vehicles":true,"rules":[]}`, nil, 400, 0},
+ {"already installed", "POST", "/packs/everyday/install", valid, alertpacks.ErrInstalled, 409, 1},
+ {"install failure", "POST", "/packs/everyday/install", valid, errors.New("secret DB detail"), 500, 1},
+ {"remove keep", "POST", "/installations/1/remove", `{"delete_rule_ids":[]}`, nil, 200, 1},
+ {"remove missing", "POST", "/installations/1/remove", `{}`, alertpacks.ErrNotFound, 404, 1},
+ {"remove foreign", "POST", "/installations/1/remove", `{"delete_rule_ids":[2]}`, alertpacks.ErrSelection, 409, 1},
+ {"remove invalid", "POST", "/installations/0/remove", `{}`, nil, 400, 0},
+ {"remove negative rule", "POST", "/installations/1/remove", `{"delete_rule_ids":[-1]}`, nil, 400, 0},
+ {"custom no name", "POST", "/packs/custom/install", valid, nil, 400, 0},
+ {"custom group", "POST", "/packs/custom/install", strings.Replace(valid, `"version":2`, `"version":2,"name":"My group"`, 1), nil, 201, 1},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ f := &packFake{err: tt.err}
+ rec := httptest.NewRecorder()
+ packRouter(f).ServeHTTP(rec, httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body)))
+ if rec.Code != tt.status || f.calls != tt.calls {
+ t.Fatalf("status=%d calls=%d body=%s", rec.Code, f.calls, rec.Body.String())
+ }
+ if strings.Contains(rec.Body.String(), "secret DB detail") {
+ t.Fatal("internal details leaked")
+ }
+ if tt.name == "empty list" && strings.TrimSpace(rec.Body.String()) != "[]" {
+ t.Fatal("list not array")
+ }
+ if tt.name == "install" && (f.templates[0].Rule.Enabled || f.templates[0].Rule.TriggerMode != "once") {
+ t.Fatal("defaults not preserved")
+ }
+ if tt.name == "custom group" && (!strings.HasPrefix(f.pack.ID, "custom-") || f.pack.Name != "My group") {
+ t.Fatal("custom name not persisted")
+ }
+ })
+ }
+}
+
+func TestEveryPackInstallsValidOrdinaryRules(t *testing.T) {
+ for _, pack := range alertpacks.Catalog() {
+ request := alertpacks.InstallRequest{Version: pack.Version, AllVehicles: true}
+ for _, template := range pack.Rules {
+ request.Rules = append(request.Rules, alertpacks.Selection{TemplateID: template.ID})
+ }
+
+ body, _ := json.Marshal(request)
+ rec := httptest.NewRecorder()
+ packRouter(&packFake{}).ServeHTTP(rec, httptest.NewRequest("POST", "/packs/"+pack.ID+"/install", strings.NewReader(string(body))))
+ if rec.Code != 201 {
+ t.Fatalf("%s: %d %s", pack.ID, rec.Code, rec.Body.String())
+ }
+ }
+}
+
+func TestPackInlineChannelValidation(t *testing.T) {
+ for _, tt := range []struct {
+ name, channels string
+ status int
+ }{
+ {"subset", "[2,3]", 201}, {"none", "[]", 201}, {"all", "null", 201},
+ {"missing", "[99]", 400}, {"duplicate", "[2,2]", 400},
+ {"negative", "[-1]", 400}, {"wrong type", `["2"]`, 400},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ repo := &packFake{}
+ body := `{"version":2,"all_vehicles":true,"rules":[{"template_id":"battery-low","op":">=","channel_ids":` + tt.channels + `}]}`
+ rec := httptest.NewRecorder()
+ packRouter(repo).ServeHTTP(rec, httptest.NewRequest("POST", "/packs/everyday/install", strings.NewReader(body)))
+ if rec.Code != tt.status {
+ t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
+ }
+
+ if tt.status != 201 && repo.calls != 0 {
+ t.Fatal("invalid channel selection was persisted")
+ }
+ if tt.status == 201 && repo.templates[0].Rule.Op != ">=" {
+ t.Fatal("reviewed operator was not persisted")
+ }
+ if tt.name == "none" && repo.templates[0].Rule.ChannelIDs == nil {
+ t.Fatal("empty channels became all channels")
+ }
+ })
+ }
+}
diff --git a/internal/api/alerts/rule_channels.go b/internal/api/alerts/rule_channels.go
new file mode 100644
index 0000000000..64533f0e42
--- /dev/null
+++ b/internal/api/alerts/rule_channels.go
@@ -0,0 +1,52 @@
+package alerts
+
+import (
+ "net/http"
+
+ "github.com/rs/zerolog/log"
+ "go.opentelemetry.io/otel"
+)
+
+func (h *AlertHandler) checkRuleChannels(w http.ResponseWriter, r *http.Request, ids []int64) bool {
+ return h.checkRuleChannelSets(w, r, ids)
+}
+
+func (h *AlertHandler) checkRuleChannelSets(w http.ResponseWriter, r *http.Request, sets ...[]int64) bool {
+ ctx, span := otel.Tracer("api").Start(r.Context(), "api.alerts.channels.validate")
+ defer span.End()
+ seen := make(map[int64]bool)
+ for _, ids := range sets {
+ if len(ids) > 100 {
+ writeError(w, http.StatusBadRequest, "select at most 100 notification channels")
+ return false
+ }
+ ruleIDs := make(map[int64]bool, len(ids))
+ for _, id := range ids {
+ if id <= 0 || ruleIDs[id] {
+ writeError(w, http.StatusBadRequest, "channel IDs must be positive and unique")
+ return false
+ }
+ ruleIDs[id], seen[id] = true, true
+ }
+ }
+ if len(seen) == 0 {
+ return true
+ }
+ channels, err := h.notifRepo.GetAllChannels(ctx)
+ if err != nil {
+ span.RecordError(err)
+ log.Error().Err(err).Str("trace_id", span.SpanContext().TraceID().String()).Msg("failed to validate rule channels")
+ writeError(w, http.StatusInternalServerError, "failed to validate notification channels")
+ return false
+ }
+ for _, ch := range channels {
+ if ch != nil {
+ delete(seen, ch.ID)
+ }
+ }
+ if len(seen) > 0 {
+ writeError(w, http.StatusBadRequest, "a selected notification channel no longer exists")
+ return false
+ }
+ return true
+}
diff --git a/internal/api/alerts/rule_channels_test.go b/internal/api/alerts/rule_channels_test.go
new file mode 100644
index 0000000000..81a36b2a19
--- /dev/null
+++ b/internal/api/alerts/rule_channels_test.go
@@ -0,0 +1,79 @@
+package alerts
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ alertmodel "github.com/ev-dev-labs/teslasync/internal/models/alert"
+ notificationmodel "github.com/ev-dev-labs/teslasync/internal/models/notification"
+ "github.com/go-chi/chi/v5"
+)
+
+type channelsFake struct {
+ fakeNotificationRepo
+ err error
+ calls int
+}
+
+func (f *channelsFake) GetAllChannels(context.Context) ([]*notificationmodel.NotificationChannel, error) {
+ f.calls++
+ return []*notificationmodel.NotificationChannel{{ID: 2}, {ID: 3}}, f.err
+}
+
+func TestRuleChannelUpdates(t *testing.T) {
+ for _, tt := range []struct {
+ name, body string
+ want int
+ ids []int64
+ dbErr error
+ }{
+ {"subset", `{"channel_ids":[2]}`, 200, []int64{2}, nil},
+ {"none", `{"channel_ids":[]}`, 200, []int64{}, nil},
+ {"all", `{"channel_ids":null}`, 200, nil, nil},
+ {"omitted preserves", `{"enabled":true}`, 200, []int64{3}, nil},
+ {"missing channel", `{"channel_ids":[99]}`, 400, nil, nil},
+ {"negative", `{"channel_ids":[-1]}`, 400, nil, nil},
+ {"duplicate", `{"channel_ids":[2,2]}`, 400, nil, nil},
+ {"string", `{"channel_ids":["2"]}`, 400, nil, nil},
+ {"lookup error", `{"channel_ids":[2]}`, 500, nil, errors.New("private db error")},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ repo := &fakeAlertRuleRepo{existing: &alertmodel.AlertRule{ID: 1, Name: "Battery", SignalName: "BatteryLevel", Op: "<", ValueNum: ptrFloat(20), Severity: "warn", CooldownMin: 60, TriggerMode: "once", Kind: "signal", AllVehicles: true, ChannelIDs: []int64{3}}}
+ h := &AlertHandler{alertRuleRepo: repo, notifRepo: &channelsFake{err: tt.dbErr}}
+ router := chi.NewRouter()
+ router.Put("/rules/{ruleID}", h.UpdateRule)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, httptest.NewRequest(http.MethodPut, "/rules/1", strings.NewReader(tt.body)))
+ if rec.Code != tt.want {
+ t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
+ }
+ if tt.want != 200 {
+ if len(repo.updated) != 0 {
+ t.Fatal("invalid request wrote rule")
+ }
+ if strings.Contains(rec.Body.String(), "private db") {
+ t.Fatal("private error exposed")
+ }
+ return
+ }
+ got := repo.updated[0]
+ if (got.ChannelIDs == nil) != (tt.ids == nil) || len(got.ChannelIDs) != len(tt.ids) {
+ t.Fatalf("wrong channels: %v", got.ChannelIDs)
+ }
+ for i := range tt.ids {
+ if got.ChannelIDs[i] != tt.ids[i] {
+ t.Fatal("wrong channel")
+ }
+ }
+ if got.Name != "Battery" || *got.ValueNum != 20 || got.CooldownMin != 60 {
+ t.Fatal("channel update changed rule content")
+ }
+ })
+ }
+}
+
+func ptrFloat(v float64) *float64 { return &v }
diff --git a/internal/api/router.go b/internal/api/router.go
index b0ef2e45a3..6470ab8a1d 100644
--- a/internal/api/router.go
+++ b/internal/api/router.go
@@ -22,6 +22,7 @@ import (
apiadminmnt "github.com/ev-dev-labs/teslasync/internal/api/adminmaintenance"
aialert "github.com/ev-dev-labs/teslasync/internal/api/aialert"
aialertmsg "github.com/ev-dev-labs/teslasync/internal/api/aialertmsg"
+ "github.com/ev-dev-labs/teslasync/internal/api/aialertpacks"
aialerttune "github.com/ev-dev-labs/teslasync/internal/api/aialerttune"
aianomaly "github.com/ev-dev-labs/teslasync/internal/api/aianomaly"
aiautomation "github.com/ev-dev-labs/teslasync/internal/api/aiautomation"
@@ -1185,6 +1186,8 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie
Validator: aialert.NewRuleValidator(),
})
alert.RegisterAlertMessageTemplateTools(aiToolRegistry)
+ alert.RegisterAlertPackTools(aiToolRegistry)
+ aiAlertPackHandler := aialertpacks.NewHandler(aiRegistry, aiToolRegistry, cfg.Auth.ForwardAuthHeader)
aiAlertMessageTemplateHandler := aialertmsg.NewHandler(
aiRegistry,
aiToolRegistry,
@@ -3744,6 +3747,11 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie
r.Post("/{alertID}/read", alertHandler.MarkRead)
r.Get("/metrics", alertHandler.ListMetrics)
r.Get("/rules", alertHandler.ListRules)
+ // Alert Packs install ordinary rules without replacing existing rules.
+ r.Get("/packs", alertHandler.ListPacks)
+ r.Get("/pack-installations", alertHandler.ListPackInstallations)
+ r.With(httprate.LimitByIP(10, time.Minute)).Post("/packs/{packID}/install", alertHandler.InstallPack)
+ r.With(httprate.LimitByIP(10, time.Minute)).Post("/pack-installations/{installationID}/remove", alertHandler.RemovePack)
r.Post("/rules", alertHandler.CreateRule)
r.Put("/rules/{ruleID}", alertHandler.UpdateRule)
r.Delete("/rules/{ruleID}", alertHandler.DeleteRule)
@@ -3751,6 +3759,7 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie
// Bulk enable/disable
r.With(httprate.LimitByIP(20, 1*time.Minute)).Post("/rules/bulk/enable", alertHandler.BulkEnableRules)
r.With(httprate.LimitByIP(20, 1*time.Minute)).Post("/rules/bulk/disable", alertHandler.BulkDisableRules)
+ r.With(httprate.LimitByIP(20, 1*time.Minute)).Post("/rules/bulk/delete", alertHandler.BulkDeleteRules)
r.Post("/test", alertHandler.TestRule)
// alert message template helpers.
// These are static read paths registered BEFORE the
@@ -5024,6 +5033,7 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie
Anomaly: aiAnomalyHandler,
Alert: aiAlertHandler,
AlertMessageTemplate: aiAlertMessageTemplateHandler,
+ AlertPacks: aiAlertPackHandler,
Automation: aiAutomationHandler,
Search: aiSearchHandler,
DriveCoach: aiDriveCoachHandler,
diff --git a/internal/api/telemetry/telemetry_alerts.go b/internal/api/telemetry/telemetry_alerts.go
index 1282541b68..36d73b28bc 100644
--- a/internal/api/telemetry/telemetry_alerts.go
+++ b/internal/api/telemetry/telemetry_alerts.go
@@ -227,7 +227,7 @@ func (e *TelemetryAlertEvaluator) fireAlert(ctx context.Context, rule *alertmode
if !quietSuppressed {
suppressTransportTitle := !rule.IncludeTitle
safeGo("notification-dispatch", func() {
- e.dispatchNotifications(title, body, severity, rule.ID, suppressTransportTitle)
+ e.dispatchNotifications(title, body, severity, rule, suppressTransportTitle)
})
}
@@ -244,7 +244,8 @@ func (e *TelemetryAlertEvaluator) fireAlert(ctx context.Context, rule *alertmode
// body-only output when the rule has IncludeTitle=false. Transports
// that REQUIRE a title (WebPush, email Subject, Pushover) ignore the
// flag and use the canonical title regardless.
-func (e *TelemetryAlertEvaluator) dispatchNotifications(title, message, severity string, ruleID int64, suppressTransportTitle bool) {
+func (e *TelemetryAlertEvaluator) dispatchNotifications(title, message, severity string, rule *alertmodel.AlertRule, suppressTransportTitle bool) {
+ ruleID := rule.ID
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -254,7 +255,7 @@ func (e *TelemetryAlertEvaluator) dispatchNotifications(title, message, severity
return
}
for _, ch := range channels {
- if !ch.Enabled {
+ if !ch.Enabled || !rule.DeliversToChannel(ch.ID) {
continue
}
req := ¬ification.Request{
diff --git a/internal/database/alert/packs.go b/internal/database/alert/packs.go
new file mode 100644
index 0000000000..0c979e9919
--- /dev/null
+++ b/internal/database/alert/packs.go
@@ -0,0 +1,152 @@
+package alert
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/ev-dev-labs/teslasync/internal/alertpacks"
+ "github.com/jackc/pgx/v5"
+)
+
+func (r *AlertRuleRepo) packReady() error {
+ if r == nil || r.db == nil || r.db.Pool == nil {
+ return errors.New("alert packs: database unavailable")
+ }
+ return nil
+}
+
+func (r *AlertRuleRepo) ListPackInstallations(ctx context.Context, limit, offset int) ([]alertpacks.Installation, error) {
+ if err := r.packReady(); err != nil {
+ return nil, err
+ }
+ rows, err := r.db.Pool.Query(ctx, `SELECT i.id, i.pack_id, i.name, i.version, i.scope_key, i.created_at,
+ m.template_id, m.rule_id, COALESCE(r.name, m.name), m.owned, COALESCE(r.enabled, false),
+ EXISTS(SELECT 1 FROM alert_pack_members other WHERE other.rule_id=m.rule_id AND other.installation_id<>i.id)
+ FROM (SELECT id, pack_id, name, version, scope_key, created_at FROM alert_pack_installations
+ ORDER BY id DESC LIMIT $1 OFFSET $2) i
+ JOIN alert_pack_members m ON m.installation_id=i.id
+ LEFT JOIN alert_rules r ON r.id=m.rule_id ORDER BY i.id DESC, m.template_id`, limit, offset)
+ if err != nil {
+ return nil, fmt.Errorf("list alert packs: %w", err)
+ }
+ defer rows.Close()
+ out := []alertpacks.Installation{}
+ for rows.Next() {
+ var i alertpacks.Installation
+ var m alertpacks.Member
+ if err := rows.Scan(&i.ID, &i.PackID, &i.Name, &i.Version, &i.ScopeKey, &i.CreatedAt,
+ &m.TemplateID, &m.RuleID, &m.Name, &m.Owned, &m.Enabled, &m.Shared); err != nil {
+ return nil, fmt.Errorf("scan alert pack: %w", err)
+ }
+ if len(out) == 0 || out[len(out)-1].ID != i.ID {
+ i.Members = []alertpacks.Member{}
+ out = append(out, i)
+ }
+ out[len(out)-1].Members = append(out[len(out)-1].Members, m)
+ }
+ return out, rows.Err()
+}
+
+// A table lock serializes pack installs with ordinary rule creation/updates.
+// This makes matching existing conditions and inserting new rules one atomic
+// operation even when another tab installs a different overlapping pack.
+func (r *AlertRuleRepo) InstallPack(ctx context.Context, pack alertpacks.Pack, scope string, templates []alertpacks.Template) (*alertpacks.Installation, error) {
+ if err := r.packReady(); err != nil {
+ return nil, err
+ }
+ if len(templates) == 0 {
+ return nil, errors.New("alert pack must include rules")
+ }
+ out := &alertpacks.Installation{PackID: pack.ID, Name: pack.Name, Version: pack.Version, ScopeKey: scope, Members: []alertpacks.Member{}}
+ err := r.db.WithTx(ctx, func(tx pgx.Tx) error {
+ if _, err := tx.Exec(ctx, `LOCK TABLE alert_rules IN SHARE ROW EXCLUSIVE MODE`); err != nil {
+ return err
+ }
+ err := tx.QueryRow(ctx, `INSERT INTO alert_pack_installations(pack_id,version,scope_key,name)
+ VALUES ($1,$2,$3,$4) ON CONFLICT (pack_id,scope_key) DO NOTHING RETURNING id,created_at`,
+ pack.ID, pack.Version, scope, pack.Name).Scan(&out.ID, &out.CreatedAt)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return alertpacks.ErrInstalled
+ }
+ if err != nil {
+ return err
+ }
+ for _, t := range templates {
+ rule := t.Rule
+ // Reuse matching trigger/scope regardless of message, severity, or
+ // enabled state. Never overwrite the user's existing configuration.
+ var id int64
+ var name string
+ var enabled bool
+ err := tx.QueryRow(ctx, `SELECT r.id,r.name,r.enabled FROM alert_rules r
+ WHERE r.kind='signal' AND r.signal_name=$1 AND r.op=$2
+ AND r.value_num IS NOT DISTINCT FROM $3::double precision
+ AND r.value_text IS NOT DISTINCT FROM $4::text
+ AND r.value_bool IS NOT DISTINCT FROM $5::boolean
+ AND r.value_min IS NOT DISTINCT FROM $6::double precision
+ AND r.value_max IS NOT DISTINCT FROM $7::double precision
+ AND r.all_vehicles=$8
+ AND ($8 OR ARRAY(SELECT vehicle_id FROM alert_rule_vehicles WHERE rule_id=r.id ORDER BY vehicle_id)=$9::bigint[])
+ ORDER BY r.id LIMIT 1`,
+ rule.SignalName, rule.Op, rule.ValueNum, rule.ValueText, rule.ValueBool,
+ rule.ValueMin, rule.ValueMax, rule.AllVehicles, rule.VehicleIDs).Scan(&id, &name, &enabled)
+ owned := errors.Is(err, pgx.ErrNoRows)
+ if owned {
+ if err := createRuleTx(ctx, tx, &rule); err != nil {
+ return fmt.Errorf("create pack rule %s: %w", t.ID, err)
+ }
+ id, name, enabled = rule.ID, rule.Name, rule.Enabled
+ } else if err != nil {
+ return err
+ }
+ if _, err := tx.Exec(ctx, `INSERT INTO alert_pack_members(installation_id,template_id,rule_id,name,owned)
+ VALUES ($1,$2,$3,$4,$5)`, out.ID, t.ID, id, name, owned); err != nil {
+ return err
+ }
+ out.Members = append(out.Members, alertpacks.Member{TemplateID: t.ID, RuleID: &id, Name: name, Owned: owned, Enabled: enabled})
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, fmt.Errorf("install alert pack: %w", err)
+ }
+ return out, nil
+}
+
+// RemovePack deletes only explicitly selected, owned, unshared rules. Empty
+// deleteIDs detaches the pack and preserves every rule, including user edits.
+func (r *AlertRuleRepo) RemovePack(ctx context.Context, id int64, deleteIDs []int64) error {
+ if err := r.packReady(); err != nil {
+ return err
+ }
+ return r.db.WithTx(ctx, func(tx pgx.Tx) error {
+ if _, err := tx.Exec(ctx, `LOCK TABLE alert_rules IN SHARE ROW EXCLUSIVE MODE`); err != nil {
+ return err
+ }
+ var found int64
+ if err := tx.QueryRow(ctx, `SELECT id FROM alert_pack_installations WHERE id=$1 FOR UPDATE`, id).Scan(&found); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return alertpacks.ErrNotFound
+ }
+ return err
+ }
+ for _, ruleID := range dedupAndSortVehicleIDs(deleteIDs) {
+ var allowed bool
+ if err := tx.QueryRow(ctx, `SELECT EXISTS(
+ SELECT 1 FROM alert_pack_members m WHERE m.installation_id=$1 AND m.rule_id=$2 AND m.owned
+ AND NOT EXISTS(SELECT 1 FROM alert_pack_members other WHERE other.rule_id=$2 AND other.installation_id<>$1))`,
+ id, ruleID).Scan(&allowed); err != nil {
+ return err
+ }
+ if !allowed {
+ return alertpacks.ErrSelection
+ }
+ if _, err := tx.Exec(ctx, `DELETE FROM alert_rules WHERE id=$1`, ruleID); err != nil {
+ return err
+ }
+ }
+ _, err := tx.Exec(ctx, `DELETE FROM alert_pack_installations WHERE id=$1`, id)
+ return err
+ })
+}
diff --git a/internal/database/alert/packs_test.go b/internal/database/alert/packs_test.go
new file mode 100644
index 0000000000..c7727c11c0
--- /dev/null
+++ b/internal/database/alert/packs_test.go
@@ -0,0 +1,204 @@
+package alert
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/ev-dev-labs/teslasync/internal/alertpacks"
+ "github.com/ev-dev-labs/teslasync/internal/database"
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+func TestPacksNilDatabase(t *testing.T) {
+ for _, repo := range []*AlertRuleRepo{nil, {}, NewAlertRuleRepo(&database.DB{})} {
+ if _, err := repo.ListPackInstallations(context.Background(), 20, 0); err == nil {
+ t.Fatal("expected unavailable")
+ }
+ if _, err := repo.InstallPack(context.Background(), alertpacks.Pack{}, "", nil); err == nil {
+ t.Fatal("expected unavailable")
+ }
+ if err := repo.RemovePack(context.Background(), 1, nil); err == nil {
+ t.Fatal("expected unavailable")
+ }
+ }
+}
+
+func TestPacksPostgres(t *testing.T) {
+ dsn := os.Getenv("TESLASYNC_TEST_DB")
+ if dsn == "" {
+ t.Skip("TESLASYNC_TEST_DB unset")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
+ defer cancel()
+ admin, err := pgxpool.New(ctx, dsn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer admin.Close()
+ schema := fmt.Sprintf("alert_packs_test_%d", time.Now().UnixNano())
+ quoted := pgx.Identifier{schema}.Sanitize()
+ if _, err := admin.Exec(ctx, "CREATE SCHEMA "+quoted); err != nil {
+ t.Fatal(err)
+ }
+ defer func() {
+ if _, err := admin.Exec(context.Background(), "DROP SCHEMA "+quoted+" CASCADE"); err != nil {
+ t.Error(err)
+ }
+ }()
+ cfg, err := pgxpool.ParseConfig(dsn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ cfg.ConnConfig.RuntimeParams["search_path"] = schema
+ pool, err := pgxpool.NewWithConfig(ctx, cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer pool.Close()
+ _, err = pool.Exec(ctx, `
+ CREATE TABLE vehicles(id BIGINT PRIMARY KEY);
+ INSERT INTO vehicles VALUES (1),(2);
+ CREATE TABLE alert_rules (LIKE public.alert_rules INCLUDING DEFAULTS INCLUDING CONSTRAINTS INCLUDING INDEXES);
+ CREATE SEQUENCE alert_pack_rule_ids OWNED BY alert_rules.id;
+ ALTER TABLE alert_rules ALTER COLUMN id SET DEFAULT nextval('alert_pack_rule_ids');
+ CREATE TABLE alert_rule_vehicles(rule_id BIGINT REFERENCES alert_rules(id) ON DELETE CASCADE,
+ vehicle_id BIGINT REFERENCES vehicles(id), PRIMARY KEY(rule_id,vehicle_id));`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ migration, err := os.ReadFile(filepath.Join("..", "..", "..", "migrations", "000245_alert_packs.up.sql"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, string(migration)); err != nil {
+ t.Fatal(err)
+ }
+ channelMigration, err := os.ReadFile(filepath.Join("..", "..", "..", "migrations", "000246_alert_rule_channels.up.sql"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, string(channelMigration)); err != nil {
+ t.Fatal(err)
+ }
+ repo := NewAlertRuleRepo(&database.DB{Pool: pool})
+ install := func(id string, ids []int64) (*alertpacks.Installation, error) {
+ pack, _ := alertpacks.Find(id)
+ req := alertpacks.InstallRequest{Version: pack.Version, AllVehicles: ids == nil, VehicleIDs: ids}
+ for _, rule := range pack.Rules {
+ req.Rules = append(req.Rules, alertpacks.Selection{TemplateID: rule.ID})
+ }
+ templates, scope, err := alertpacks.Prepare(pack, req)
+ if err != nil {
+ return nil, err
+ }
+ return repo.InstallPack(ctx, pack, scope, templates)
+ }
+ first, err := install("everyday", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ everyday, _ := alertpacks.Find("everyday")
+ if len(first.Members) != len(everyday.Rules) {
+ t.Fatal("missing members")
+ }
+ ruleID := *first.Members[0].RuleID
+ roundTrip, err := repo.GetByID(ctx, ruleID)
+ if err != nil || roundTrip == nil || roundTrip.ChannelIDs != nil {
+ t.Fatalf("default channels: rule=%+v err=%v", roundTrip, err)
+ }
+ for _, channelIDs := range [][]int64{{2, 3}, {}, nil} {
+ roundTrip.ChannelIDs = channelIDs
+ if err := repo.Update(ctx, ruleID, roundTrip); err != nil {
+ t.Fatal(err)
+ }
+ roundTrip, err = repo.GetByID(ctx, ruleID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if (roundTrip.ChannelIDs == nil) != (channelIDs == nil) || len(roundTrip.ChannelIDs) != len(channelIDs) {
+ t.Fatal("channel selection round trip lost all/none/subset distinction")
+ }
+ }
+ if _, err := pool.Exec(ctx, `UPDATE alert_rules SET name='User edited',msg_template='Keep me',enabled=true WHERE id=$1`, ruleID); err != nil {
+ t.Fatal(err)
+ }
+ second, err := install("trip", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var originalCount int
+ if err := pool.QueryRow(ctx, `SELECT count(*) FROM alert_rules`).Scan(&originalCount); err != nil {
+ t.Fatal(err)
+ }
+ if second.Members[0].Owned || second.Members[0].Name != "User edited" || !second.Members[0].Enabled {
+ t.Fatal("existing edits overwritten")
+ }
+ if err := repo.RemovePack(ctx, first.ID, []int64{ruleID}); !errors.Is(err, alertpacks.ErrSelection) {
+ t.Fatalf("shared rule deleted: %v", err)
+ }
+ if err := repo.RemovePack(ctx, second.ID, nil); err != nil {
+ t.Fatal(err)
+ }
+ if err := repo.RemovePack(ctx, first.ID, []int64{ruleID}); err != nil {
+ t.Fatal(err)
+ }
+ var count int
+ if err := pool.QueryRow(ctx, `SELECT count(*) FROM alert_rules`).Scan(&count); err != nil || count != originalCount-1 {
+ t.Fatalf("unselected rules lost count=%d err=%v", count, err)
+ }
+
+ // Roll back both installation and all created rules on a bad vehicle FK.
+ if _, err := install("security", []int64{999}); err == nil {
+ t.Fatal("missing vehicle accepted")
+ }
+ if err := pool.QueryRow(ctx, `SELECT count(*) FROM alert_pack_installations`).Scan(&count); err != nil || count != 0 {
+ t.Fatalf("partial install persisted count=%d err=%v", count, err)
+ }
+ var wg sync.WaitGroup
+ results := make(chan error, 2)
+ for range 2 {
+ wg.Add(1)
+ go func() { defer wg.Done(); _, err := install("charging", []int64{2, 1}); results <- err }()
+ }
+ wg.Wait()
+ close(results)
+ success, duplicate := 0, 0
+ for err := range results {
+ if err == nil {
+ success++
+ } else if errors.Is(err, alertpacks.ErrInstalled) {
+ duplicate++
+ } else {
+ t.Fatal(err)
+ }
+ }
+ if success != 1 || duplicate != 1 {
+ t.Fatalf("concurrent results success=%d duplicate=%d", success, duplicate)
+ }
+ list, err := repo.ListPackInstallations(ctx, 20, 0)
+ charging, _ := alertpacks.Find("charging")
+ if err != nil || len(list) != 1 || len(list[0].Members) != len(charging.Rules) || list[0].ScopeKey != "1,2" {
+ t.Fatalf("list=%+v err=%v", list, err)
+ }
+ deletedID := *list[0].Members[0].RuleID
+ if deleted, err := repo.BulkDelete(ctx, []int64{deletedID, deletedID, -1}); err != nil || len(deleted) != 1 || deleted[0] != deletedID {
+ t.Fatal(err)
+ }
+ list, err = repo.ListPackInstallations(ctx, 20, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if list[0].Members[0].RuleID != nil {
+ t.Fatal("deleted member must remain visibly missing")
+ }
+ if err := repo.RemovePack(ctx, list[0].ID, nil); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/internal/database/alert/repo.go b/internal/database/alert/repo.go
index 373682ad2e..34af0195ca 100644
--- a/internal/database/alert/repo.go
+++ b/internal/database/alert/repo.go
@@ -34,7 +34,7 @@ const alertRuleColumns = `id, name, description, enabled, vehicle_id, all_vehicl
max_fires_per_resolution,
escalation_after_min, escalation_severity,
msg_template, include_title,
- created_at, updated_at`
+ created_at, updated_at, channel_ids`
func scanAlertRule(row interface{ Scan(dest ...any) error }, ar *alertmodel.AlertRule) error {
return row.Scan(
@@ -47,6 +47,7 @@ func scanAlertRule(row interface{ Scan(dest ...any) error }, ar *alertmodel.Aler
&ar.EscalationAfterMin, &ar.EscalationSeverity,
&ar.MsgTemplate, &ar.IncludeTitle,
&ar.CreatedAt, &ar.UpdatedAt,
+ &ar.ChannelIDs,
)
}
@@ -253,7 +254,7 @@ func (r *AlertRuleRepo) Update(ctx context.Context, id int64, rule *alertmodel.A
max_fires_per_resolution=$23,
escalation_after_min=$24, escalation_severity=$25,
msg_template=$26, include_title=$27,
- updated_at=$28
+ updated_at=$28, channel_ids=$29
WHERE id=$1`,
id, rule.Name, rule.Description, rule.Enabled, rule.VehicleID,
rule.AllVehicles,
@@ -264,7 +265,7 @@ func (r *AlertRuleRepo) Update(ctx context.Context, id int64, rule *alertmodel.A
rule.MaxFiresPerResolution,
rule.EscalationAfterMin, rule.EscalationSeverity,
rule.MsgTemplate, rule.IncludeTitle,
- time.Now().UTC())
+ time.Now().UTC(), rule.ChannelIDs)
if err != nil {
return err
}
@@ -314,6 +315,15 @@ func (r *AlertRuleRepo) GetByID(ctx context.Context, id int64) (*alertmodel.Aler
// Create inserts the rule and its junction rows in a single transaction.
// It uses the same validation and legacy-column mirroring contract as Update.
func (r *AlertRuleRepo) Create(ctx context.Context, rule *alertmodel.AlertRule) error {
+ if err := validateVehicleSelection(rule.AllVehicles, rule.VehicleIDs); err != nil {
+ return err
+ }
+ return r.db.WithTx(ctx, func(tx pgx.Tx) error {
+ return createRuleTx(ctx, tx, rule)
+ })
+}
+
+func createRuleTx(ctx context.Context, tx pgx.Tx, rule *alertmodel.AlertRule) error {
if rule.Kind == "" {
rule.Kind = alertmodel.AlertRuleKindSignal
}
@@ -324,8 +334,7 @@ func (r *AlertRuleRepo) Create(ctx context.Context, rule *alertmodel.AlertRule)
rule.VehicleIDs = vehicleIDs
rule.VehicleID = legacyVehicleIDFor(rule.AllVehicles, vehicleIDs)
- return r.db.WithTx(ctx, func(tx pgx.Tx) error {
- query := `INSERT INTO alert_rules (name, description, enabled, vehicle_id,
+ query := `INSERT INTO alert_rules (name, description, enabled, vehicle_id,
all_vehicles,
signal_name, op,
value_num, value_text, value_bool, value_min, value_max,
@@ -334,44 +343,43 @@ func (r *AlertRuleRepo) Create(ctx context.Context, rule *alertmodel.AlertRule)
max_fires_per_resolution,
escalation_after_min, escalation_severity,
msg_template, include_title,
- created_at, updated_at)
+ created_at, updated_at, channel_ids)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
- $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, NOW(), NOW())
+ $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, NOW(), NOW(), $27)
RETURNING id, created_at, updated_at`
- err := tx.QueryRow(ctx, query, rule.Name, rule.Description, rule.Enabled,
- rule.VehicleID, rule.AllVehicles,
- rule.SignalName, rule.Op, rule.ValueNum, rule.ValueText,
- rule.ValueBool, rule.ValueMin, rule.ValueMax, rule.Severity, rule.CooldownMin,
- rule.TriggerMode, rule.SnoozedUntil,
- rule.Kind, rule.MetricID, rule.MetricWindow, rule.MetricThreshold, rule.MetricOp,
- rule.MaxFiresPerResolution,
- rule.EscalationAfterMin, rule.EscalationSeverity,
- rule.MsgTemplate, rule.IncludeTitle).
- Scan(&rule.ID, &rule.CreatedAt, &rule.UpdatedAt)
- if err != nil {
- return err
- }
- if !rule.AllVehicles && len(vehicleIDs) > 0 {
- batch := &pgx.Batch{}
- for _, vid := range vehicleIDs {
- batch.Queue(
- `INSERT INTO alert_rule_vehicles (rule_id, vehicle_id) VALUES ($1, $2)
+ err := tx.QueryRow(ctx, query, rule.Name, rule.Description, rule.Enabled,
+ rule.VehicleID, rule.AllVehicles,
+ rule.SignalName, rule.Op, rule.ValueNum, rule.ValueText,
+ rule.ValueBool, rule.ValueMin, rule.ValueMax, rule.Severity, rule.CooldownMin,
+ rule.TriggerMode, rule.SnoozedUntil,
+ rule.Kind, rule.MetricID, rule.MetricWindow, rule.MetricThreshold, rule.MetricOp,
+ rule.MaxFiresPerResolution,
+ rule.EscalationAfterMin, rule.EscalationSeverity,
+ rule.MsgTemplate, rule.IncludeTitle, rule.ChannelIDs).
+ Scan(&rule.ID, &rule.CreatedAt, &rule.UpdatedAt)
+ if err != nil {
+ return err
+ }
+ if !rule.AllVehicles && len(vehicleIDs) > 0 {
+ batch := &pgx.Batch{}
+ for _, vid := range vehicleIDs {
+ batch.Queue(
+ `INSERT INTO alert_rule_vehicles (rule_id, vehicle_id) VALUES ($1, $2)
ON CONFLICT DO NOTHING`,
- rule.ID, vid)
- }
- br := tx.SendBatch(ctx, batch)
- defer br.Close()
- for range vehicleIDs {
- if _, err := br.Exec(); err != nil {
- return err
- }
- }
- if err := br.Close(); err != nil {
+ rule.ID, vid)
+ }
+ br := tx.SendBatch(ctx, batch)
+ defer br.Close()
+ for range vehicleIDs {
+ if _, err := br.Exec(); err != nil {
return err
}
}
- return nil
- })
+ if err := br.Close(); err != nil {
+ return err
+ }
+ }
+ return nil
}
func (r *AlertRuleRepo) Delete(ctx context.Context, id int64) error {
@@ -380,6 +388,23 @@ func (r *AlertRuleRepo) Delete(ctx context.Context, id int64) error {
return err
}
+func (r *AlertRuleRepo) BulkDelete(ctx context.Context, ids []int64) ([]int64, error) {
+ rows, err := r.db.Pool.Query(ctx, `DELETE FROM alert_rules WHERE id = ANY($1) RETURNING id`, ids)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ deleted := []int64{}
+ for rows.Next() {
+ var id int64
+ if err := rows.Scan(&id); err != nil {
+ return nil, err
+ }
+ deleted = append(deleted, id)
+ }
+ return deleted, rows.Err()
+}
+
// FilterExistingIDs returns the subset of `ids` that exist in alert_rules.
// Used by bulk handlers to surface {id, "not_found"} per-id failures.
func (r *AlertRuleRepo) FilterExistingIDs(ctx context.Context, ids []int64) ([]int64, error) {
diff --git a/internal/models/alert/alert.go b/internal/models/alert/alert.go
index bfdd2d5cf0..dbb5f4b564 100644
--- a/internal/models/alert/alert.go
+++ b/internal/models/alert/alert.go
@@ -114,11 +114,28 @@ type AlertRule struct {
// body-only output; the canonical title is still persisted in
// notification_logs and broadcast over SSE so the in-app UI is unaffected.
IncludeTitle bool `db:"include_title" json:"include_title"`
+ // Nil preserves all-channel delivery; an empty selection disables external delivery.
+ ChannelIDs []int64 `db:"channel_ids" json:"channel_ids"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
+func (r *AlertRule) DeliversToChannel(id int64) bool {
+ if r == nil {
+ return false
+ }
+ if r.ChannelIDs == nil {
+ return true
+ }
+ for _, channelID := range r.ChannelIDs {
+ if channelID == id {
+ return true
+ }
+ }
+ return false
+}
+
// AppliesTo reports whether this rule should be evaluated against the
// given vehicle. Sticky-all rules (AllVehicles=true) match every
// vehicle including ones inserted AFTER the rule was created (D7
diff --git a/internal/models/alert/channels_test.go b/internal/models/alert/channels_test.go
new file mode 100644
index 0000000000..6e917c1c83
--- /dev/null
+++ b/internal/models/alert/channels_test.go
@@ -0,0 +1,41 @@
+package alert
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestChannelRouting(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ rule *AlertRule
+ id int64
+ want bool
+ }{
+ {"nil rule", nil, 1, false},
+ {"legacy all", &AlertRule{}, 1, true},
+ {"future channel", &AlertRule{}, 99, true},
+ {"no external", &AlertRule{ChannelIDs: []int64{}}, 1, false},
+ {"included", &AlertRule{ChannelIDs: []int64{2, 3}}, 2, true},
+ {"excluded", &AlertRule{ChannelIDs: []int64{2, 3}}, 1, false},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := tt.rule.DeliversToChannel(tt.id); got != tt.want {
+ t.Fatalf("got %v want %v", got, tt.want)
+ }
+ })
+ }
+ for _, ids := range [][]int64{nil, {}, {2}} {
+ data, err := json.Marshal(AlertRule{ChannelIDs: ids})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var decoded AlertRule
+ if err := json.Unmarshal(data, &decoded); err != nil {
+ t.Fatal(err)
+ }
+ if (decoded.ChannelIDs == nil) != (ids == nil) || len(decoded.ChannelIDs) != len(ids) {
+ t.Fatal("wire lost all/none distinction")
+ }
+ }
+}
diff --git a/migrations/000245_alert_packs.down.sql b/migrations/000245_alert_packs.down.sql
new file mode 100644
index 0000000000..9a7f4a70cf
--- /dev/null
+++ b/migrations/000245_alert_packs.down.sql
@@ -0,0 +1,3 @@
+-- Uninstalling the feature leaves the ordinary alert rules intact.
+DROP TABLE IF EXISTS alert_pack_members;
+DROP TABLE IF EXISTS alert_pack_installations;
diff --git a/migrations/000245_alert_packs.up.sql b/migrations/000245_alert_packs.up.sql
new file mode 100644
index 0000000000..53fef7d447
--- /dev/null
+++ b/migrations/000245_alert_packs.up.sql
@@ -0,0 +1,19 @@
+CREATE TABLE IF NOT EXISTS alert_pack_installations (
+ id BIGSERIAL PRIMARY KEY,
+ pack_id TEXT NOT NULL,
+ name TEXT NOT NULL,
+ version INTEGER NOT NULL CHECK (version > 0),
+ scope_key TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE (pack_id, scope_key)
+);
+
+CREATE TABLE IF NOT EXISTS alert_pack_members (
+ installation_id BIGINT NOT NULL REFERENCES alert_pack_installations(id) ON DELETE CASCADE,
+ template_id TEXT NOT NULL,
+ rule_id BIGINT REFERENCES alert_rules(id) ON DELETE SET NULL,
+ name TEXT NOT NULL,
+ owned BOOLEAN NOT NULL,
+ PRIMARY KEY (installation_id, template_id)
+);
+CREATE INDEX IF NOT EXISTS idx_alert_pack_members_rule ON alert_pack_members(rule_id);
diff --git a/migrations/000246_alert_rule_channels.down.sql b/migrations/000246_alert_rule_channels.down.sql
new file mode 100644
index 0000000000..2c0a358a35
--- /dev/null
+++ b/migrations/000246_alert_rule_channels.down.sql
@@ -0,0 +1 @@
+ALTER TABLE alert_rules DROP COLUMN IF EXISTS channel_ids;
diff --git a/migrations/000246_alert_rule_channels.up.sql b/migrations/000246_alert_rule_channels.up.sql
new file mode 100644
index 0000000000..849babc955
--- /dev/null
+++ b/migrations/000246_alert_rule_channels.up.sql
@@ -0,0 +1,2 @@
+ALTER TABLE alert_rules ADD COLUMN IF NOT EXISTS channel_ids BIGINT[];
+COMMENT ON COLUMN alert_rules.channel_ids IS 'NULL uses all enabled external channels; empty array disables external delivery; otherwise restrict to these channel IDs. Browser delivery is unchanged.';
diff --git a/ops/migrations/manifest.yaml b/ops/migrations/manifest.yaml
index bfdb5bf0a4..a3c65c2992 100644
--- a/ops/migrations/manifest.yaml
+++ b/ops/migrations/manifest.yaml
@@ -373,3 +373,29 @@ migrations:
reversible: true
reviewed_by: "@atulmgupta"
reviewed_on: "2026-09-14"
+
+ - version: 245
+ name: alert_packs
+ forward_compatible: true
+ forward_compatibility_notes: New installation and membership tables; ordinary alert rules unchanged.
+ rollback_notes: Leave applied on rollback. Down removes pack tracking, never alert rules.
+ expected_duration: <1s
+ duration_basis: estimate
+ lock_risk: low
+ lock_details: Creates two empty tables and an index; foreign keys briefly lock referenced tables.
+ reversible: true
+ reviewed_by: "@Copilot"
+ reviewed_on: "2026-09-18"
+
+ - version: 246
+ name: alert_rule_channels
+ forward_compatible: true
+ forward_compatibility_notes: Nullable channel selection preserves existing all-channel delivery.
+ rollback_notes: Leave applied on rollback. Older binaries ignore channel selections and deliver to all channels; disable restricted rules before rollback. Down removes selections.
+ expected_duration: <1s
+ duration_basis: estimate
+ lock_risk: low
+ lock_details: Brief ACCESS EXCLUSIVE lock for nullable column addition; no table rewrite.
+ reversible: true
+ reviewed_by: "@Copilot"
+ reviewed_on: "2026-09-18"
diff --git a/slo/catalog.yaml b/slo/catalog.yaml
index f8213f4c64..713d7b62d5 100644
--- a/slo/catalog.yaml
+++ b/slo/catalog.yaml
@@ -14,6 +14,26 @@
version: 1
slos:
+ - name: alert_packs_availability
+ description: "Alert pack catalog, installations, removal and bulk rule deletion endpoints must avoid server errors."
+ sli:
+ good_events: "sum(rate(teslasync_red_http_requests_total{route=~\"/api/v1/alerts/(packs|pack-installations|rules/bulk/delete).*\",status_class!=\"5xx\"}[5m]))"
+ valid_events: "sum(rate(teslasync_red_http_requests_total{route=~\"/api/v1/alerts/(packs|pack-installations|rules/bulk/delete).*\"}[5m]))"
+ objective: 99.5
+ window: 30d
+ owner: notifications
+ tags: [http, alerts]
+
+ - name: alert_pack_ai_availability
+ description: "Opt-in Helix custom pack proposals must avoid server errors."
+ sli:
+ good_events: "sum(rate(teslasync_red_http_requests_total{route=\"/api/v1/ai/alerts/packs/draft\",status_class!=\"5xx\"}[5m]))"
+ valid_events: "sum(rate(teslasync_red_http_requests_total{route=\"/api/v1/ai/alerts/packs/draft\"}[5m]))"
+ objective: 99.0
+ window: 30d
+ owner: notifications
+ tags: [http, ai, alerts]
+
- name: api_availability
description: "HTTP request success ratio across /api/v1/. Counts non-5xx responses as good events."
sli:
diff --git a/web/e2e/.snapshots/visual-1440-dark/visual.spec.ts/settings.png b/web/e2e/.snapshots/visual-1440-dark/visual.spec.ts/settings.png
index 1049728aa1..4bbe532337 100644
Binary files a/web/e2e/.snapshots/visual-1440-dark/visual.spec.ts/settings.png and b/web/e2e/.snapshots/visual-1440-dark/visual.spec.ts/settings.png differ
diff --git a/web/e2e/.snapshots/visual-1440-light/visual.spec.ts/settings.png b/web/e2e/.snapshots/visual-1440-light/visual.spec.ts/settings.png
index 2d08d0a1f1..1614bb3fd2 100644
Binary files a/web/e2e/.snapshots/visual-1440-light/visual.spec.ts/settings.png and b/web/e2e/.snapshots/visual-1440-light/visual.spec.ts/settings.png differ
diff --git a/web/e2e/.snapshots/visual-390-dark/visual.spec.ts/settings.png b/web/e2e/.snapshots/visual-390-dark/visual.spec.ts/settings.png
index 8f61161042..08d6ed6ce6 100644
Binary files a/web/e2e/.snapshots/visual-390-dark/visual.spec.ts/settings.png and b/web/e2e/.snapshots/visual-390-dark/visual.spec.ts/settings.png differ
diff --git a/web/e2e/.snapshots/visual-390-light/visual.spec.ts/settings.png b/web/e2e/.snapshots/visual-390-light/visual.spec.ts/settings.png
index 67c03c4d14..df06d4fa38 100644
Binary files a/web/e2e/.snapshots/visual-390-light/visual.spec.ts/settings.png and b/web/e2e/.snapshots/visual-390-light/visual.spec.ts/settings.png differ
diff --git a/web/e2e/.snapshots/visual-long-content/visual.spec.ts/battery.png b/web/e2e/.snapshots/visual-long-content/visual.spec.ts/battery.png
index 5fbb6e60e2..e293fdbed5 100644
Binary files a/web/e2e/.snapshots/visual-long-content/visual.spec.ts/battery.png and b/web/e2e/.snapshots/visual-long-content/visual.spec.ts/battery.png differ
diff --git a/web/e2e/.snapshots/visual-long-content/visual.spec.ts/settings.png b/web/e2e/.snapshots/visual-long-content/visual.spec.ts/settings.png
index 55ce71e092..0bc26257ab 100644
Binary files a/web/e2e/.snapshots/visual-long-content/visual.spec.ts/settings.png and b/web/e2e/.snapshots/visual-long-content/visual.spec.ts/settings.png differ
diff --git a/web/e2e/alert-packs.smoke.spec.ts b/web/e2e/alert-packs.smoke.spec.ts
new file mode 100644
index 0000000000..251a98a6f6
--- /dev/null
+++ b/web/e2e/alert-packs.smoke.spec.ts
@@ -0,0 +1,264 @@
+import { expect, test } from '@playwright/test'
+import { assertMockApiComplete, fulfillApiMock, installApiMocks, seedBrowserState, waitForHarnessReady } from './mockApi'
+import type { AlertPack } from '../src/api/hooks/useAlertPacks'
+import type { AlertRule } from '../src/api/hooks/useNotifications'
+
+const pack: AlertPack = {
+ id: 'all', version: 2, name: 'All alerts', description: 'Complete supported catalog',
+ rules: Array.from({ length: 12 }, (_, index) => ({
+ id: `browser-rule-${index}`, unit: '%',
+ rule: { id: 0, name: `Battery reminder ${index + 1}`, enabled: false, all_vehicles: true, vehicle_ids: [],
+ signal_name: 'BatteryLevel', op: '<', value_num: 20, severity: 'warn', cooldown_min: 60,
+ trigger_mode: 'once', kind: 'signal', include_title: true, msg_template: '{{VehicleName}}: {{Value}}%',
+ created_at: '', updated_at: '' },
+ })),
+}
+
+for (const width of [390, 1440]) {
+for (const theme of ['light', 'dark'] as const) {
+test(`rule management is contextual at ${width}px ${theme}`, async ({ page }) => {
+ await page.setViewportSize({ width, height: 1000 })
+ await seedBrowserState(page, theme, '/notifications/studio')
+ const api = await installApiMocks(page, 'populated', theme)
+ await page.route('**/api/v1/signals/*/available', route => fulfillApiMock(route, api, { json: { vehicle_id: 7, count: 0, signals: [] } }))
+ let rules: AlertRule[] = pack.rules.slice(0, 3).map((template, index) => ({
+ ...template.rule, id: index + 1, channel_ids: index === 0 ? [2] : index === 1 ? [3] : null,
+ }))
+ const updates: unknown[] = []
+ const deletions: number[][] = []
+ await page.route('**/api/v1/notifications', route => fulfillApiMock(route, api, { json: [
+ { id: 2, name: 'Team', kind: 'ntfy', enabled: true, config: {}, created_at: '', updated_at: '' },
+ { id: 3, name: 'Phone', kind: 'ntfy', enabled: true, config: {}, created_at: '', updated_at: '' },
+ ] }))
+ await page.route('**/api/v1/alerts/rules', route => fulfillApiMock(route, api, { json: rules }))
+ await page.route('**/api/v1/alerts/rules/1', async route => {
+ const update: { channel_ids: number[] | null } = route.request().postDataJSON()
+ updates.push(update)
+ rules = rules.map(rule => rule.id === 1 ? { ...rule, ...update } : rule)
+ await fulfillApiMock(route, api, { json: rules[0] })
+ })
+ await page.route('**/api/v1/alerts/rules/bulk/delete', async route => {
+ const { ids }: { ids: number[] } = route.request().postDataJSON()
+ deletions.push(ids)
+ rules = rules.filter(rule => !ids.includes(rule.id))
+ await fulfillApiMock(route, api, { json: { deleted_ids: ids } })
+ })
+ await page.goto('/notifications/studio', { waitUntil: 'domcontentloaded' })
+ await waitForHarnessReady(page, api)
+ const toolbar = page.getByRole('region', { name: 'Bulk actions for selected items' })
+ await expect(toolbar).toHaveCount(0)
+ await page.getByRole('region', { name: 'Rules', exact: true }).screenshot({ path: test.info().outputPath('rules-browse.png') })
+ const filter = page.getByLabel('Filter by notification channel')
+ await filter.selectOption('2')
+ await expect(page.getByText('Battery reminder 1', { exact: true })).toBeVisible()
+ await expect(page.getByText('Battery reminder 2', { exact: true })).toHaveCount(0)
+ await expect(page.getByText('Battery reminder 3', { exact: true })).toBeVisible()
+ await page.getByRole('button', { name: 'Channels for Battery reminder 1' }).click()
+ const channelDialog = page.getByRole('dialog', { name: 'Channels for Battery reminder 1' })
+ await channelDialog.getByText('Team (ntfy)', { exact: true }).click()
+ await channelDialog.getByText('Phone (ntfy)', { exact: true }).click()
+ await expect(channelDialog.getByRole('checkbox', { name: 'Team (ntfy)' })).not.toBeChecked()
+ await expect(channelDialog.getByRole('checkbox', { name: 'Phone (ntfy)' })).toBeChecked()
+ await channelDialog.getByRole('button', { name: 'Save', exact: true }).click()
+ await expect(channelDialog).toHaveCount(0)
+ expect(updates).toEqual([{ channel_ids: [3] }])
+ await expect(page.getByText('Battery reminder 1', { exact: true })).toHaveCount(0)
+ await page.getByText('Select all', { exact: true }).click()
+ await expect(toolbar).toBeVisible()
+ await expect(toolbar.getByRole('button', { name: 'Delete', exact: true })).toHaveCount(1)
+ expect(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1)).toBe(false)
+ await toolbar.screenshot({ path: test.info().outputPath('rule-selection.png') })
+ await page.getByRole('region', { name: 'Rules', exact: true }).screenshot({ path: test.info().outputPath('rules-selected.png') })
+ await toolbar.getByRole('button', { name: 'Delete', exact: true }).click()
+ const confirmation = page.getByRole('dialog', { name: 'Delete 1 rule?' })
+ await confirmation.getByRole('button', { name: 'Cancel', exact: true }).click()
+ expect(deletions).toHaveLength(0)
+ await toolbar.getByRole('button', { name: 'Delete', exact: true }).click()
+ await confirmation.getByRole('button', { name: 'Delete', exact: true }).click()
+ await expect(page.getByText('No rules match the current search and channel filter.')).toBeVisible()
+ expect(deletions).toEqual([[3]])
+ expect(rules.map(rule => rule.id)).toEqual([1, 2])
+ await filter.selectOption('')
+ await expect(page.getByText('Battery reminder 1', { exact: true })).toBeVisible()
+ await expect(page.getByText('Battery reminder 2', { exact: true })).toBeVisible()
+ await assertMockApiComplete(page, api)
+})
+}
+}
+
+for (const theme of ['light', 'dark'] as const) {
+ test(`empty rule list avoids irrelevant controls in ${theme}`, async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 1000 })
+ await seedBrowserState(page, theme, '/notifications/studio')
+ const api = await installApiMocks(page, 'empty', theme)
+ await page.goto('/notifications/studio', { waitUntil: 'domcontentloaded' })
+ await waitForHarnessReady(page, api)
+ await expect(page.getByText('No alert rules yet', { exact: true })).toBeVisible()
+ await expect(page.getByRole('combobox', { name: 'Filter by notification channel' })).toHaveCount(0)
+ await expect(page.getByRole('checkbox', { name: 'Select all matching rules' })).toHaveCount(0)
+ await expect(page.getByRole('region', { name: 'Bulk actions for selected items' })).toHaveCount(0)
+ await expect(page.getByRole('button', { name: 'Delete all matching rules' })).toHaveCount(0)
+ await page.getByRole('region', { name: 'Rules', exact: true }).screenshot({ path: test.info().outputPath('rules-empty.png') })
+ await assertMockApiComplete(page, api)
+ })
+}
+
+for (const width of [320, 390, 768, 1024, 1280, 1440, 1920, 2560]) {
+ for (const theme of ['light', 'dark'] as const) {
+ test(`alert pack controls and Helix proposal stay readable at ${width}px ${theme}`, async ({ page }) => {
+ await page.setViewportSize({ width, height: 1000 })
+ await seedBrowserState(page, theme, '/notifications/studio')
+ const api = await installApiMocks(page, 'populated', theme)
+ await page.route('**/api/v1/signals/*/available', route => fulfillApiMock(route, api, { json: { vehicle_id: 7, count: 0, signals: [] } }))
+ await page.route('**/api/v1/settings', route => fulfillApiMock(route, api, { json: {
+ mode: theme, language: 'en', unit_of_length: 'km', unit_of_temp: 'C', unit_of_pressure: 'bar',
+ ai_mode: 'hybrid', ai_features: { 'alert-pack-builder': true, 'alert-message-template-suggestion': true },
+ } }))
+ await page.route('**/api/v1/alerts/packs', route => fulfillApiMock(route, api, { json: [pack, { ...pack, id: 'custom', name: 'Custom pack' }] }))
+ await page.route('**/api/v1/notifications', route => fulfillApiMock(route, api, { json: [
+ { id: 2, name: 'Phone', kind: 'ntfy', enabled: true, config: {}, created_at: '', updated_at: '' },
+ { id: 3, name: 'Team', kind: 'ntfy', enabled: true, config: {}, created_at: '', updated_at: '' },
+ ] }))
+ await page.route('**/api/v1/ai/alerts/packs/draft', route => fulfillApiMock(route, api, {
+ contentType: 'text/event-stream',
+ body: [
+ { type: 'tool_result', id: 'proposal', name: 'propose_alert_pack', ok: true, data: {
+ status: 'ok', name: 'Complete ownership watch for every supported event',
+ rationale: 'A comprehensive set of supported reminders, including battery and charging events. Review all rules and their individual cooldowns before installation.',
+ template_ids: pack.rules.map(rule => rule.id),
+ } },
+ { type: 'delta', text: 'Review this comprehensive proposal before installation.' },
+ { type: 'done', finish_reason: 'stop', usage: { in: 20, out: 50 } },
+ ].map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(''),
+ }))
+ const installations: unknown[] = []
+ const messageRequests: Record
[] = []
+ await page.route('**/api/v1/ai/alerts/message-template/draft', async route => {
+ messageRequests.push(route.request().postDataJSON())
+ await fulfillApiMock(route, api, {
+ contentType: 'text/event-stream',
+ body: [
+ { type: 'tool_result', id: 'message', name: 'validate_alert_message_template', ok: true,
+ data: { status: 'ok', template: '{{VehicleName}} is ready for its next charging chapter.', used_placeholders: ['VehicleName'] } },
+ { type: 'done', finish_reason: 'stop', usage: { in: 20, out: 20 } },
+ ].map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(''),
+ })
+ })
+ await page.route('**/api/v1/alerts/packs/*/install', async route => {
+ installations.push(route.request().postDataJSON())
+ await fulfillApiMock(route, api, { json: { id: 1, pack_id: 'all', name: 'All alerts', version: 2, scope_key: 'all', created_at: '', members: [] } })
+ })
+ await page.goto('/notifications/studio', { waitUntil: 'domcontentloaded' })
+ await waitForHarnessReady(page, api)
+ await page.getByRole('button', { name: 'Alert Packs', exact: true }).click()
+ const helix = page.getByTestId('ai-feature-alert-pack-builder-root')
+ await helix.getByLabel('What would you like to keep an eye on?').fill('A comprehensive pack for every supported event')
+ await helix.getByRole('button', { name: /Suggest an alert pack/ }).click()
+ const heading = helix.getByRole('heading', { name: 'Complete ownership watch for every supported event' })
+ await expect(heading).toBeVisible()
+ await expect(helix.getByText('12 proposed rules')).toBeVisible()
+ const explanation = helix.getByText(/^A comprehensive set of supported reminders/)
+ const review = helix.getByRole('button', { name: 'Review this proposed pack' })
+ const titleBox = await heading.boundingBox()
+ const explanationBox = await explanation.boundingBox()
+ const reviewBox = await review.boundingBox()
+ expect(explanationBox!.y).toBeGreaterThanOrEqual(titleBox!.y + titleBox!.height)
+ expect(reviewBox!.y).toBeGreaterThan(explanationBox!.y + explanationBox!.height)
+ expect(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1)).toBe(false)
+ await helix.screenshot({ path: test.info().outputPath('helix-proposal.png') })
+ await review.click()
+ const dialog = page.getByRole('dialog', { name: /^Preview Complete ownership watch/ })
+ await expect(dialog.getByLabel('Notification message', { exact: true })).toHaveCount(10)
+ await expect(dialog.getByRole('table', { name: 'Choose rules' })).toHaveCount(width >= 1024 ? 1 : 0)
+ const footer = dialog.locator('[data-modal-footer]')
+ const footerBox = await footer.boundingBox()
+ expect(footerBox!.y + footerBox!.height).toBeLessThanOrEqual(1001)
+ await expect(dialog.getByRole('button', { name: /^Customize / })).toHaveCount(0)
+ await dialog.screenshot({ path: test.info().outputPath('pack-overview.png') })
+ if (width < 1024) await dialog.getByRole('button', { name: /Pack defaults/ }).click()
+ await expect(dialog.getByLabel('Default cooldown (minutes)')).toBeVisible()
+ await expect(dialog.getByLabel('Default cooldown (minutes)')).toHaveValue('15')
+ const controls = dialog.locator('[data-pack-default-controls]').locator('input, select, button[aria-haspopup="listbox"]')
+ await expect(controls).toHaveCount(6)
+ const positions = await controls.evaluateAll(elements => elements.map(element => element.getBoundingClientRect().y))
+ const columns = width >= 1536 ? 6 : width >= 1024 ? 3 : width >= 640 ? 2 : 1
+ for (let start = 0; start < positions.length; start += columns) {
+ const row = positions.slice(start, start + columns)
+ expect(Math.max(...row) - Math.min(...row), 'Default controls must align within each responsive row').toBeLessThanOrEqual(2)
+ }
+ await expect(dialog.getByLabel('Default alert behavior').getByRole('option')).toHaveText(['Re-alert until resolved', 'Notify on event'])
+ if (width >= 1024) {
+ const table = dialog.getByRole('table', { name: 'Choose rules' })
+ await expect(table.getByRole('columnheader')).toHaveText([
+ 'Selected', 'Rule', 'Operator', 'Value', 'Cooldown (minutes)', 'Alert behavior', 'Channels', 'Notification message', 'Include title', 'Defaults',
+ ])
+ expect(await table.getByRole('columnheader').evaluateAll(headers => headers.every(header => getComputedStyle(header).textAlign === 'left'))).toBe(true)
+ const row = table.locator('tbody tr').first()
+ expect((await row.boundingBox())!.height, 'Rows must be compact, not stacked mini-forms').toBeLessThanOrEqual(100)
+ expect(await row.getByRole('cell').evaluateAll(cells => cells.every(cell => cell.querySelectorAll('input,select,textarea').length <= 1))).toBe(true)
+ const rowPositions = await row.locator('input:not([type="checkbox"]), select, textarea').evaluateAll(elements => elements.map(element => element.getBoundingClientRect().y))
+ expect(Math.max(...rowPositions) - Math.min(...rowPositions), 'Each cell editor must start at the same vertical position').toBeLessThanOrEqual(2)
+ }
+ await dialog.getByLabel('Default cooldown (minutes)').fill('15')
+ await dialog.getByLabel('Default alert behavior').selectOption('repeat')
+ await dialog.getByLabel('Default channels').selectOption('2')
+ await expect(dialog.getByLabel('Channels', { exact: true }).first()).toHaveValue('custom')
+ await dialog.locator('[data-pack-default-controls]').screenshot({ path: test.info().outputPath('pack-default-channels.png') })
+ if (width < 1024) await dialog.getByRole('button', { name: /Pack defaults/ }).click()
+ await dialog.getByLabel('Channels', { exact: true }).first().selectOption('all')
+ await dialog.getByLabel('Channels', { exact: true }).nth(1).selectOption('none')
+ await expect(dialog.getByLabel('Alert behavior', { exact: true }).first()).toHaveValue('repeat')
+ await expect(dialog.getByRole('option', { name: /^Master:/ })).toHaveCount(0)
+ await dialog.getByLabel('Minimum minutes between notifications', { exact: true }).first().fill('120')
+ await dialog.getByLabel('Operator', { exact: true }).first().selectOption('<=')
+ await dialog.getByLabel('Threshold (%)', { exact: true }).first().fill('25')
+ await dialog.getByLabel('Notification message', { exact: true }).first().fill('{{VehicleName}} manual draft')
+ const secondMessage = await dialog.getByLabel('Notification message', { exact: true }).nth(1).inputValue()
+ await dialog.getByRole('button', { name: 'Suggest a message for Battery reminder 1', exact: true }).click()
+ const messageDialog = page.getByRole('dialog', { name: 'Suggest a message for Battery reminder 1', exact: true })
+ await messageDialog.getByTestId('ai-feature-alert-message-template-suggestion-suggest').click()
+ await expect(messageDialog.getByText('{{VehicleName}} is ready for its next charging chapter.', { exact: true })).toBeVisible()
+ expect(installations).toHaveLength(0)
+ expect(await dialog.getByLabel('Notification message', { exact: true }).first().inputValue()).toBe('{{VehicleName}} manual draft')
+ await messageDialog.getByTestId('ai-feature-alert-message-template-suggestion-apply').click()
+ await expect(messageDialog).toHaveCount(0)
+ expect(messageRequests).toHaveLength(1)
+ expect(messageRequests[0]).toMatchObject({ name: 'Battery reminder 1', op: '<=', value_num: 25 })
+ await expect(dialog.getByLabel('Notification message', { exact: true }).first()).toHaveValue('{{VehicleName}} is ready for its next charging chapter.')
+ await expect(dialog.getByLabel('Notification message', { exact: true }).nth(1)).toHaveValue(secondMessage)
+ if (width >= 1024) {
+ await page.setViewportSize({ width: 390, height: 1000 })
+ await expect(dialog.getByRole('table')).toHaveCount(0)
+ await expect(dialog.getByLabel('Minimum minutes between notifications').first()).toHaveValue('120')
+ await page.setViewportSize({ width, height: 1000 })
+ await expect(dialog.getByRole('table')).toBeVisible()
+ await expect(dialog.getByLabel('Minimum minutes between notifications').first()).toHaveValue('120')
+ }
+ await dialog.getByRole('button', { name: 'Next', exact: true }).click()
+ await expect(dialog.getByText('Page 2 of 2')).toBeVisible()
+ await dialog.getByRole('button', { name: 'Previous', exact: true }).click()
+ await expect(dialog.getByLabel('Minimum minutes between notifications', { exact: true }).first()).toHaveValue('120')
+ await dialog.screenshot({ path: test.info().outputPath('pack-rule-editor.png') })
+ if (width < 1024) await dialog.getByRole('button', { name: /Pack defaults/ }).click()
+ await dialog.getByRole('button', { name: 'Apply defaults to all rules' }).click()
+ await expect(dialog.getByLabel('Minimum minutes between notifications', { exact: true }).first()).toHaveValue('15')
+ await expect(dialog.getByLabel('Channels', { exact: true }).first()).toHaveValue('custom')
+ await expect(dialog.getByLabel('Channels', { exact: true }).nth(1)).toHaveValue('custom')
+ if (width < 1024) await dialog.getByRole('button', { name: /Pack defaults/ }).click()
+ expect(await dialog.evaluate(el => el.scrollWidth > el.clientWidth + 1)).toBe(false)
+ await dialog.screenshot({ path: test.info().outputPath('pack-controls.png') })
+ expect(installations).toHaveLength(0)
+ await dialog.getByRole('button', { name: 'Install selected rules' }).click()
+ await expect(dialog.getByText(/Pack installed/)).toBeVisible()
+ expect(installations).toHaveLength(1)
+ expect(installations[0]).toMatchObject({ cooldown_s: 900, trigger_mode: 'repeat', enabled: false })
+ expect(installations[0]).not.toHaveProperty('channel_ids')
+ expect(installations[0]).toMatchObject({ rules: pack.rules.map(() => ({ channel_ids: [2] })) })
+ expect((installations[0] as { rules: unknown[] }).rules).toHaveLength(12)
+ expect((installations[0] as { rules: unknown[] }).rules[0]).toMatchObject({
+ op: '<=', value_num: 25, message: '{{VehicleName}} is ready for its next charging chapter.',
+ })
+ await assertMockApiComplete(page, api)
+ })
+ }
+}
diff --git a/web/e2e/harness.contract.spec.ts b/web/e2e/harness.contract.spec.ts
index 3cd89a64af..69e7c6e682 100644
--- a/web/e2e/harness.contract.spec.ts
+++ b/web/e2e/harness.contract.spec.ts
@@ -1,6 +1,7 @@
import { expect, test } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
+import { load } from 'js-yaml';
import { AXE_DEBT_BY_ROUTE } from './axeBaseline';
import { isSensitiveRun, resolveStorageState } from './configEnv';
import { isSsePath, resolveApiFixture } from './mockApi';
@@ -136,31 +137,44 @@ test('authenticated smoke records only sanitized aggregate status', () => {
expect(productionJob).not.toContain('web/playwright-report/');
});
-test('CI builds once per browser job and reuses one preview', () => {
- const workflow = readFileSync(resolve(process.cwd(), '..', '.github', 'workflows', 'frontend-quality.yml'), 'utf8');
- const job = (name: string, next?: string) => {
- const start = workflow.split(` ${name}:`)[1];
- return next ? start.split(` ${next}:`)[0] : start;
+test('CI shares one hermetic build and gives each browser shard one managed preview', () => {
+ interface WorkflowStep {
+ run?: string;
+ uses?: string;
+ with?: Record;
+ env?: Record;
+ }
+ const workflow = load(readFileSync(
+ resolve(process.cwd(), '..', '.github', 'workflows', 'frontend-quality.yml'), 'utf8',
+ )) as { jobs: Record };
+ const build = workflow.jobs['browser-build'];
+ const buildSteps = Object.values(workflow.jobs).flatMap(job =>
+ job.steps.filter(step => step.run?.includes('npm run e2e:build')));
+ expect(buildSteps).toHaveLength(1);
+ expect(build.steps).toContain(buildSteps[0]);
+ expect(buildSteps[0].env).toMatchObject({ E2E_MOCKS: '1' });
+ expect(build.steps.find(step => step.uses?.startsWith('actions/upload-artifact'))?.with)
+ .toMatchObject({ name: 'e2e-app', path: 'web/e2e/.app-dist/', 'if-no-files-found': 'error' });
+
+ for (const [id, command] of [
+ ['chromium-tests', 'npm run e2e:${{ matrix.suite }} -- --shard=${{ matrix.shard }}'],
+ ['cross-browser', 'npm run e2e -- --project=${{ matrix.browser }}-smoke'],
+ ['visual-tests', 'npm run e2e:visual -- --shard=${{ matrix.shard }}/4 --workers=2'],
+ ]) {
+ const job = workflow.jobs[id];
+ expect(job.needs).toBe('browser-build');
+ expect(job.steps.find(step => step.uses?.startsWith('actions/download-artifact'))?.with)
+ .toMatchObject({ name: 'e2e-app', path: 'web/e2e/.app-dist' });
+ expect(job.steps.find(step => step.run === command)?.env)
+ .toMatchObject({ E2E_REUSE_BUILD: '1', E2E_MOCKS: '1' });
+ }
+
+ const { scripts } = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as {
+ scripts: Record;
};
- const count = (source: string, value: string) => source.split(value).length - 1;
- const contract = job('contract', 'chromium-quality');
- const chromium = job('chromium-quality', 'cross-browser');
- const crossBrowser = job('cross-browser', 'visual');
- const visual = job('visual', 'authenticated-production-smoke');
- const authenticated = job('authenticated-production-smoke');
-
- expect(count(contract, 'npm run e2e:build')).toBe(0);
- expect(count(chromium, 'npm run e2e:build')).toBe(1);
- expect(count(crossBrowser, 'npm run e2e:build')).toBe(1);
- expect(count(visual, 'npm run e2e:build')).toBe(1);
- expect(count(authenticated, 'npm run e2e:build')).toBe(0);
- expect(chromium).toContain('npm run e2e:quality:run');
- expect(chromium).toContain('npm run e2e:performance:run');
- expect(chromium).toContain('npm run e2e:a11y:run');
- expect(crossBrowser).toContain('E2E_SKIP_WEBSERVER=1');
- expect(visual).toContain("npm run e2e:visual:run");
- expect(workflow).not.toContain('npm run e2e:quality\n');
- expect(workflow).not.toContain('npm run e2e:visual\n');
+ for (const suite of ['e2e', 'e2e:quality', 'e2e:performance', 'e2e:a11y', 'e2e:visual']) {
+ expect(scripts[suite]).toBe(`node scripts/run-e2e-suite.mjs ${suite}:run`);
+ }
});
test('local suite wrapper separates build time from preview readiness', () => {
diff --git a/web/e2e/mockApi.ts b/web/e2e/mockApi.ts
index e47b347ff6..e8dadd49bb 100644
--- a/web/e2e/mockApi.ts
+++ b/web/e2e/mockApi.ts
@@ -790,6 +790,27 @@ async function fulfill(
}
}
+// Per-test fixtures must participate in the same no-network-escape accounting.
+export async function fulfillApiMock(
+ route: Route,
+ controller: MockApiController | null,
+ response: Parameters[0],
+): Promise {
+ if (!controller) throw new Error('Custom API fixtures require E2E mocks');
+ const request = route.request();
+ const record = requestRecord(controller, request);
+ const url = new URL(request.url());
+ controller.seen.add(`${request.method()} ${url.pathname.replace(/^\/api\/v1/, '')}${url.search}`);
+ controller.pending += 1;
+ try {
+ await route.fulfill(response);
+ if (record) record.disposition = 'fulfilled';
+ } finally {
+ controller.pending -= 1;
+ controller.lastActivityAt = Date.now();
+ }
+}
+
export async function installApiMocks(
page: Page,
scenario: DataScenario = 'populated',
diff --git a/web/e2e/settings.smoke.spec.ts b/web/e2e/settings.smoke.spec.ts
new file mode 100644
index 0000000000..79b22e124a
--- /dev/null
+++ b/web/e2e/settings.smoke.spec.ts
@@ -0,0 +1,102 @@
+import { expect, test } from '@playwright/test'
+import { assertMockApiComplete, installApiMocks, seedBrowserState, waitForHarnessReady } from './mockApi'
+
+for (const { width, theme, scale } of [320, 390, 768, 1024, 1280, 1440, 1920, 2560].flatMap(width =>
+ (['dark', 'light'] as const).flatMap(theme => [1, 1.35].map(scale => ({ width, theme, scale }))))) {
+ test(`settings categories stay readable and preserve edits at ${width}px in ${theme} with ${scale}x text`, async ({ page }, testInfo) => {
+ await page.setViewportSize({ width, height: 900 })
+ await seedBrowserState(page, theme, '/settings')
+ const api = await installApiMocks(page, 'populated', theme)
+ const writes: string[] = []
+ page.on('request', request => {
+ if (request.method() === 'PUT' && new URL(request.url()).pathname === '/api/v1/settings') writes.push(request.url())
+ })
+ await page.goto('/settings', { waitUntil: 'domcontentloaded' })
+ await waitForHarnessReady(page, api)
+ await page.evaluate(scale => document.documentElement.style.setProperty('--font-scale', String(scale)), scale)
+ await expect(page.locator('html')).toHaveCSS('--font-scale', String(scale))
+ const selectCategory = async (id: string, label: RegExp) => {
+ if (width < 1024) {
+ await page.getByRole('combobox', { name: 'Settings categories', exact: true }).selectOption(id)
+ } else {
+ await page.getByRole('navigation', { name: 'Settings categories', exact: true }).getByRole('button', { name: label }).click()
+ }
+ }
+ await expect(page.locator('#overview')).toBeVisible()
+ await expect(page.locator('#general')).toBeHidden()
+ await expect(page.locator('#reset')).toBeHidden()
+ const actionCard = page.locator('[data-tour="settings-tour"]')
+ const description = actionCard.locator('p')
+ const action = actionCard.getByRole('button', { name: /Open Tour Launcher/ })
+ const descriptionBox = await description.boundingBox()
+ const actionBox = await action.boundingBox()
+ expect(descriptionBox).not.toBeNull()
+ expect(actionBox).not.toBeNull()
+ expect(descriptionBox!.width).toBeGreaterThanOrEqual(180)
+ expect(actionBox!.y).toBeGreaterThanOrEqual(descriptionBox!.y + descriptionBox!.height)
+ await expect(actionCard.getByRole('heading')).toBeVisible()
+ expect(await actionCard.getByRole('heading').evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true)
+ const shortcutCards = page.getByRole('region', { name: 'Settings shortcuts', exact: true }).locator('[data-print-card]')
+ await expect(shortcutCards).toHaveCount(3)
+ for (const [index, card] of (await shortcutCards.all()).entries()) {
+ const paragraph = card.locator('p')
+ const box = await paragraph.boundingBox()
+ expect(box!.width).toBeGreaterThanOrEqual(180)
+ const button = card.getByRole('button')
+ if (await button.count()) {
+ const buttonBox = await button.boundingBox()
+ expect(buttonBox!.y).toBeGreaterThanOrEqual(box!.y + box!.height)
+ expect(buttonBox!.height).toBeGreaterThanOrEqual(44)
+ await button.click({ trial: true })
+ }
+ await card.scrollIntoViewIfNeeded()
+ await card.screenshot({ path: testInfo.outputPath(`settings-shortcut-${index + 1}.png`) })
+ }
+ const categoryDescription = page.getByText('Current preferences and useful shortcuts', { exact: true }).filter({ visible: true }).last()
+ const saveHint = page.getByText('Each section keeps its existing save controls. Switching categories keeps your unsaved edits.', { exact: true })
+ const categoryBox = await categoryDescription.boundingBox()
+ const hintBox = await saveHint.boundingBox()
+ expect(hintBox!.y).toBeGreaterThanOrEqual(categoryBox!.y + categoryBox!.height)
+ const overflowText = await page.locator('#overview [data-print-card]').evaluateAll(cards => cards.flatMap(card => {
+ const bounds = card.getBoundingClientRect()
+ const walker = document.createTreeWalker(card, NodeFilter.SHOW_TEXT)
+ const overflow: string[] = []
+ while (walker.nextNode()) {
+ const node = walker.currentNode
+ if (!node.textContent?.trim()) continue
+ const range = document.createRange()
+ range.selectNodeContents(node)
+ if ([...range.getClientRects()].some(rect => rect.left < bounds.left || rect.right > bounds.right + 1)) {
+ overflow.push(node.textContent)
+ }
+ }
+ return overflow
+ }))
+ expect(overflowText, 'Overview text must fit inside its card, not clip or spill into adjacent cards').toEqual([])
+ const noOverflow = async () => {
+ expect(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 1)).toBe(false)
+ }
+ await noOverflow()
+ await selectCategory('general', /Units, language & costs/)
+ const distance = page.getByRole('combobox', { name: /^Distance Unit$/i })
+ await distance.selectOption('mi')
+ await selectCategory('typography', /Fonts & readability/)
+ await expect(page.locator('#typography')).toBeVisible()
+ await expect(distance).toBeHidden()
+ await selectCategory('general', /Units, language & costs/)
+ await expect(distance).toHaveValue('mi')
+ expect(writes).toEqual([])
+ await expect(page.getByRole('dialog')).toHaveCount(0)
+ await noOverflow()
+ for (const [id, label] of [
+ ['appearance', /Appearance & experience/],
+ ['workspace', /Workspace/],
+ ['reset', /Reset & recovery/],
+ ] as const) {
+ await selectCategory(id, label)
+ await expect(page.locator(`#${id}`)).toBeVisible()
+ await noOverflow()
+ }
+ await assertMockApiComplete(page, api)
+ })
+}
diff --git a/web/e2e/visual.spec.ts b/web/e2e/visual.spec.ts
index 1aaf5c0ce5..ae3b2cb6f9 100644
--- a/web/e2e/visual.spec.ts
+++ b/web/e2e/visual.spec.ts
@@ -66,7 +66,7 @@ async function stressVisibleCopy(page: Page): Promise {
copy.length >= 3 &&
copy.length <= 80 &&
parent &&
- !parent.closest('[aria-hidden="true"], [role="contentinfo"], script, style, svg')
+ !parent.closest('[aria-hidden="true"], footer, [role="contentinfo"], script, style, svg')
) {
nodes.push(node);
}
diff --git a/web/scripts/audit-live-mutations.mjs b/web/scripts/audit-live-mutations.mjs
index 583885513a..c7e1757439 100644
--- a/web/scripts/audit-live-mutations.mjs
+++ b/web/scripts/audit-live-mutations.mjs
@@ -109,6 +109,8 @@ const MODE_INDEPENDENT_MUTATIONS = {
],
'hooks/useAiSettings.ts': ['useSaveAiSettings', 'useValidateAiProvider'],
'hooks/useAlertMessageHelpers.ts': ['useAlertMessagePreview'],
+ 'hooks/useAlertPacks.ts': ['useInstallAlertPack', 'useRemoveAlertPack'],
+ 'hooks/useBulkDeleteAlertRules.ts': ['useBulkDeleteAlertRules'],
'hooks/useAnnotations.ts': [
'useCreateAnnotation',
'useUpdateAnnotation',
diff --git a/web/scripts/audit-virtualization.mjs b/web/scripts/audit-virtualization.mjs
index a525a08e35..4c87bb7a34 100644
--- a/web/scripts/audit-virtualization.mjs
+++ b/web/scripts/audit-virtualization.mjs
@@ -115,7 +115,7 @@ const WAIVER_RE = /\/\/\s*virtualize-audit:skip\b/;
export const LONG_LIST_ADMISSIONS = [
['pagination', / {
// ─────────────────────────────────────────────────────────────────────────────
describe('virtualization backlog: derived from a source scan', () => {
+ it('keeps rule-list rendering in the backlog when its bulk controls are extracted', () => {
+ const source = `
+ export default function RulesPage() {
+ return {rules.map(rule => )}
+ }
+ `
+ const verdict = classifyLongListSource(source)
+ expect(verdict.isLongListSurface).toBe(true)
+ expect(verdict.reasons).toContain('bulk-actions')
+ })
+
const LONG_LIST_PAGE = `
import { Pagination } from '@/components/ui'
export default function NewThingListPage() {
diff --git a/web/src/ai/features.ts b/web/src/ai/features.ts
index 3b548ceef0..88d2336435 100644
--- a/web/src/ai/features.ts
+++ b/web/src/ai/features.ts
@@ -10,7 +10,7 @@
//
// Phase-50 / 0001 — F0 AI-Off Contract (ADR-015).
-export type AiFeatureId = "__redaction_bypass__" | "__usage__" | "ai-provider-health" | "alert-message-template-suggestion" | "alert-tuning-suggestions" | "anomaly-explanations" | "auto-name-unnamed-locations" | "auto-trip-naming" | "battery-health-forecast-narrative" | "cabin-temperature-impact-narrative" | "charging-curve-fingerprint-clustering" | "charging-diagnosis" | "chatbot-llm" | "cost-forecast-narration" | "cross-rule-conflict-detection" | "data-repair-suggestions" | "digest-narration" | "drive-coaching" | "feedback-queue-triage" | "geofence-aware-automation-suggestions" | "inbox-auto-categorization" | "incident-timeline-summarizer" | "learned-per-vehicle-anomaly-baselines" | "lifetime-stats-qa" | "log-trace-summarization" | "ml-charging-curve-clustering" | "mqtt-sse-inspector-explanations" | "nl-alert-builder" | "nl-automation-builder" | "nl-dashboard-composer" | "nl-drive-search-replay" | "nl-grafana-panel" | "nl-search" | "nl-sql-playground" | "period-compare-narration" | "pii-redaction-shared-exports" | "predictive-maintenance" | "preheat-precool-recommender" | "quiet-hours-suggestion" | "rag-help" | "range-prediction-model" | "route-efficiency-suggestions" | "safety-setting-explainer" | "signal-explorer-nl-filter" | "smart-charge-schedule-suggestion" | "software-update-changelog-summarizer" | "speed-profile-insights" | "state-machine-debugger-narrator" | "suggest-new-geofences" | "tco-narration" | "tire-pressure-trend-reasoning" | "trip-planner-llm-agent" | "trip-postcard-share-card-image-generation" | "vampire-drain-explanation" | "vehicle-paint-preview" | "voice-mode" | "watch-face-nl-response" | "yir-narration";
+export type AiFeatureId = "__redaction_bypass__" | "__usage__" | "ai-provider-health" | "alert-message-template-suggestion" | "alert-pack-builder" | "alert-tuning-suggestions" | "anomaly-explanations" | "auto-name-unnamed-locations" | "auto-trip-naming" | "battery-health-forecast-narrative" | "cabin-temperature-impact-narrative" | "charging-curve-fingerprint-clustering" | "charging-diagnosis" | "chatbot-llm" | "cost-forecast-narration" | "cross-rule-conflict-detection" | "data-repair-suggestions" | "digest-narration" | "drive-coaching" | "feedback-queue-triage" | "geofence-aware-automation-suggestions" | "inbox-auto-categorization" | "incident-timeline-summarizer" | "learned-per-vehicle-anomaly-baselines" | "lifetime-stats-qa" | "log-trace-summarization" | "ml-charging-curve-clustering" | "mqtt-sse-inspector-explanations" | "nl-alert-builder" | "nl-automation-builder" | "nl-dashboard-composer" | "nl-drive-search-replay" | "nl-grafana-panel" | "nl-search" | "nl-sql-playground" | "period-compare-narration" | "pii-redaction-shared-exports" | "predictive-maintenance" | "preheat-precool-recommender" | "quiet-hours-suggestion" | "rag-help" | "range-prediction-model" | "route-efficiency-suggestions" | "safety-setting-explainer" | "signal-explorer-nl-filter" | "smart-charge-schedule-suggestion" | "software-update-changelog-summarizer" | "speed-profile-insights" | "state-machine-debugger-narrator" | "suggest-new-geofences" | "tco-narration" | "tire-pressure-trend-reasoning" | "trip-planner-llm-agent" | "trip-postcard-share-card-image-generation" | "vampire-drain-explanation" | "vehicle-paint-preview" | "voice-mode" | "watch-face-nl-response" | "yir-narration";
export interface AiFeatureMeta {
readonly id: AiFeatureId;
@@ -69,6 +69,17 @@ export const AI_FEATURES: Readonly> = Object.
needsStream: true,
uiTestIds: Object.freeze(["ai-feature-alert-message-template-suggestion-root"] as const),
}),
+ "alert-pack-builder": Object.freeze({
+ id: "alert-pack-builder",
+ name: "Helix custom Alert Packs",
+ description: "Proposes a goal-based group from supported alert templates. Review and edit the draft before explicit installation. Never changes rules autonomously.",
+ tier: "A",
+ defaultOn: false,
+ needsRag: false,
+ needsTools: true,
+ needsStream: true,
+ uiTestIds: Object.freeze(["ai-feature-alert-pack-builder-root"] as const),
+ }),
"alert-tuning-suggestions": Object.freeze({
id: "alert-tuning-suggestions",
name: "Alert tuning suggestions",
@@ -670,6 +681,7 @@ export const AI_FEATURE_IDS: readonly AiFeatureId[] = Object.freeze([
"__usage__",
"ai-provider-health",
"alert-message-template-suggestion",
+ "alert-pack-builder",
"alert-tuning-suggestions",
"anomaly-explanations",
"auto-name-unnamed-locations",
diff --git a/web/src/ai/spaWiring.ts b/web/src/ai/spaWiring.ts
index a3972f0f08..b79244b15d 100644
--- a/web/src/ai/spaWiring.ts
+++ b/web/src/ai/spaWiring.ts
@@ -30,6 +30,15 @@ export interface SPAWiringEntry {
}
export const SPA_WIRING: ReadonlyArray = Object.freeze([
+ Object.freeze({
+ featureId: "alert-pack-builder",
+ component: "components/ai/AIAlertPackBuilder.tsx",
+ endpoint: "POST /api/v1/ai/alerts/packs/draft",
+ endpointPath: "/ai/alerts/packs/draft",
+ method: "POST",
+ render: "proposal",
+ baselineFormHandoff: "/notifications/studio",
+ }),
Object.freeze({
featureId: "alert-message-template-suggestion",
component: "components/ai/AIAlertMessageTemplateSuggestion.tsx",
@@ -528,6 +537,15 @@ export const SPA_WIRING: ReadonlyArray = Object.freeze([
] as const);
export const SPA_WIRING_BY_ID: Readonly> = Object.freeze({
+ "alert-pack-builder": Object.freeze({
+ featureId: "alert-pack-builder",
+ component: "components/ai/AIAlertPackBuilder.tsx",
+ endpoint: "POST /api/v1/ai/alerts/packs/draft",
+ endpointPath: "/ai/alerts/packs/draft",
+ method: "POST",
+ render: "proposal",
+ baselineFormHandoff: "/notifications/studio",
+ }),
"alert-message-template-suggestion": Object.freeze({
featureId: "alert-message-template-suggestion",
component: "components/ai/AIAlertMessageTemplateSuggestion.tsx",
diff --git a/web/src/api/hooks/useAiSettings.ts b/web/src/api/hooks/useAiSettings.ts
index 2e49b84240..4ab2c5efe0 100644
--- a/web/src/api/hooks/useAiSettings.ts
+++ b/web/src/api/hooks/useAiSettings.ts
@@ -20,8 +20,8 @@ import type { AppSettings } from '@/api/types'
*
* Mirrors `validateConfigRequest` in
* `internal/api/ai_settings_validate_handler.go`. Cloud mode uses the
- * extended set (api_key / model / api_version / flavor / deployment /
- * embedding_*); local mode only consults `mode` + `base_url`. All
+ * extended set (api_key / model / api_protocol / embedding_model);
+ * local mode only consults `mode` + `base_url`. All
* cloud fields are optional and fall back to the saved per-provider
* entry server-side, so editing one field doesn't force the user to
* re-state the rest.
@@ -32,11 +32,8 @@ export interface ValidateAiProviderRequest {
base_url?: string
api_key?: string
model?: string
- api_version?: string
- flavor?: string
- deployment?: string
+ api_protocol?: string
embedding_model?: string
- embedding_deployment?: string
}
/**
@@ -76,9 +73,8 @@ export interface ValidateAiProviderSuccess {
* no registered adapter.
* - `missing_api_key` — cloud probe needs an API key (request
* omitted it AND no saved key fallback).
- * - `missing_base_url` — Azure flavor needs a resource endpoint.
- * - `missing_deployment`— Azure OpenAI Service flavor needs a
- * deployment name (or model) to route to.
+ * - `missing_base_url` — Foundry needs a resource endpoint.
+ * - `missing_deployment`— Foundry needs a deployment name.
* - `unauthorized` — provider returned 401/403 (bad key).
* - `not_found` — provider returned 404 (bad URL or
* deployment slug).
diff --git a/web/src/api/hooks/useAlertPacks.test.tsx b/web/src/api/hooks/useAlertPacks.test.tsx
new file mode 100644
index 0000000000..d5bc102abb
--- /dev/null
+++ b/web/src/api/hooks/useAlertPacks.test.tsx
@@ -0,0 +1,38 @@
+import type { ReactNode } from 'react'
+import { beforeEach, expect, it, vi } from 'vitest'
+import { act, renderHook, waitFor } from '@testing-library/react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { request } from '@/api/client'
+import { invalidateAndBroadcast } from '@/lib/queryBroadcast'
+import { packKeys, useAlertPacks, usePackInstallations, useInstallAlertPack, useRemoveAlertPack } from './useAlertPacks'
+
+vi.mock('@/api/client', () => ({ request: vi.fn() }))
+vi.mock('@/lib/queryBroadcast', () => ({ invalidateAndBroadcast: vi.fn() }))
+vi.mock('@/api/queryPolicy', () => ({ queryPolicy: () => ({ retry: false }) }))
+
+function wrapper({ children }: { children: ReactNode }) {
+ return {children}
+}
+beforeEach(() => vi.clearAllMocks())
+
+it('normalizes nullable catalog and member arrays', async () => {
+ vi.mocked(request).mockResolvedValueOnce([{ id: 'x', rules: null }]).mockResolvedValueOnce([{ id: 1, members: null }])
+ const catalog = renderHook(() => useAlertPacks(), { wrapper })
+ await waitFor(() => expect(catalog.result.current.data?.[0].rules).toEqual([]))
+ const installed = renderHook(() => usePackInstallations(2), { wrapper })
+ await waitFor(() => expect(installed.result.current.data?.[0].members).toEqual([]))
+ expect(request).toHaveBeenCalledWith('/alerts/pack-installations?limit=20&offset=40', expect.objectContaining({ signal: expect.any(AbortSignal) }))
+})
+
+it('invalidates both rule and pack caches after installing and removing', async () => {
+ vi.mocked(request).mockResolvedValue({ id: 1, members: [] })
+ const install = renderHook(() => useInstallAlertPack(), { wrapper })
+ await act(async () => {
+ await install.result.current.mutateAsync({ pack_id: 'custom', name: 'Weekend', version: 1, enabled: false, all_vehicles: true, vehicle_ids: [], rules: [{ template_id: 'battery-low' }] })
+ })
+ expect(invalidateAndBroadcast).toHaveBeenCalledWith(expect.anything(), { queryKey: ['alert-rules'] })
+ expect(invalidateAndBroadcast).toHaveBeenCalledWith(expect.anything(), { queryKey: packKeys.installations })
+ const remove = renderHook(() => useRemoveAlertPack(), { wrapper })
+ await act(async () => { await remove.result.current.mutateAsync({ id: 1, delete_rule_ids: [] }) })
+ expect(request).toHaveBeenLastCalledWith('/alerts/pack-installations/1/remove', { method: 'POST', body: '{"delete_rule_ids":[]}' })
+})
diff --git a/web/src/api/hooks/useAlertPacks.ts b/web/src/api/hooks/useAlertPacks.ts
new file mode 100644
index 0000000000..a612e7f92f
--- /dev/null
+++ b/web/src/api/hooks/useAlertPacks.ts
@@ -0,0 +1,115 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { request } from '@/api/client'
+import { queryPolicy } from '@/api/queryPolicy'
+import type { AlertRule } from '@/api/types'
+import { safeArray } from '@/lib/safeArray'
+import { invalidateAndBroadcast } from '@/lib/queryBroadcast'
+import { notificationKeys } from './useNotifications'
+
+export interface PackTemplate {
+ id: string
+ unit: string
+ rule: AlertRule
+}
+
+export interface AlertPack {
+ id: string
+ version: number
+ name: string
+ description: string
+ rules: PackTemplate[]
+}
+
+export interface PackMember {
+ template_id: string
+ rule_id: number | null
+ name: string
+ owned: boolean
+ enabled: boolean
+ shared: boolean
+}
+
+export interface PackInstallation {
+ id: number
+ pack_id: string
+ name: string
+ version: number
+ scope_key: string
+ created_at: string
+ members: PackMember[]
+}
+
+export interface PackSelection {
+ template_id: string
+ op?: string
+ channel_ids?: number[] | null
+ value_num?: number
+ message?: string
+ cooldown_s?: number
+ trigger_mode?: 'once' | 'repeat'
+ include_title?: boolean
+}
+
+export interface InstallPackInput {
+ name?: string
+ pack_id: string
+ version: number
+ all_vehicles: boolean
+ vehicle_ids: number[]
+ enabled: boolean
+ cooldown_s?: number
+ trigger_mode?: 'once' | 'repeat'
+ include_title?: boolean
+ rules: PackSelection[]
+}
+
+export const packKeys = {
+ catalog: ['alert-packs'] as const,
+ installations: notificationKeys.packInstallations,
+}
+
+export function useAlertPacks() {
+ return useQuery({
+ queryKey: packKeys.catalog,
+ queryFn: ({ signal }) => request('/alerts/packs', { signal }),
+ select: data => safeArray(data).map(pack => ({ ...pack, rules: safeArray(pack.rules) })),
+ ...queryPolicy('reference'),
+ })
+}
+
+export function usePackInstallations(page: number) {
+ return useQuery({
+ queryKey: [...packKeys.installations, page],
+ queryFn: ({ signal }) => request(`/alerts/pack-installations?limit=20&offset=${page * 20}`, { signal }),
+ select: data => safeArray(data).map(item => ({ ...item, members: safeArray(item.members) })),
+ ...queryPolicy('operational'),
+ })
+}
+
+export function useInstallAlertPack() {
+ const client = useQueryClient()
+ return useMutation({
+ mutationFn: ({ pack_id, ...body }: InstallPackInput) =>
+ request(`/alerts/packs/${encodeURIComponent(pack_id)}/install`, {
+ method: 'POST', body: JSON.stringify(body),
+ }),
+ onSuccess: () => {
+ invalidateAndBroadcast(client, { queryKey: notificationKeys.alertRules })
+ invalidateAndBroadcast(client, { queryKey: packKeys.installations })
+ },
+ })
+}
+
+export function useRemoveAlertPack() {
+ const client = useQueryClient()
+ return useMutation({
+ mutationFn: ({ id, delete_rule_ids }: { id: number; delete_rule_ids: number[] }) =>
+ request<{ status: string }>(`/alerts/pack-installations/${id}/remove`, {
+ method: 'POST', body: JSON.stringify({ delete_rule_ids }),
+ }),
+ onSuccess: () => {
+ invalidateAndBroadcast(client, { queryKey: notificationKeys.alertRules })
+ invalidateAndBroadcast(client, { queryKey: packKeys.installations })
+ },
+ })
+}
diff --git a/web/src/api/hooks/useBulkDeleteAlertRules.test.tsx b/web/src/api/hooks/useBulkDeleteAlertRules.test.tsx
new file mode 100644
index 0000000000..1ba943f4b6
--- /dev/null
+++ b/web/src/api/hooks/useBulkDeleteAlertRules.test.tsx
@@ -0,0 +1,48 @@
+import { act, renderHook, waitFor } from '@testing-library/react'
+import { beforeEach, expect, it, vi } from 'vitest'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { request } from '@/api/client'
+import { useBulkDeleteAlertRules } from './useBulkDeleteAlertRules'
+import { notificationKeys } from './useNotifications'
+
+vi.mock('@/api/client', () => ({ request: vi.fn() }))
+const toasts = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }))
+vi.mock('./_toastHelpers', () => ({ useMutationToast: () => toasts }))
+beforeEach(() => { vi.mocked(request).mockReset(); vi.clearAllMocks() })
+
+function setup() {
+ const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
+ client.setQueryData(notificationKeys.alertRules, [])
+ client.setQueryData(notificationKeys.packInstallations, [])
+ const hook = renderHook(() => useBulkDeleteAlertRules(), {
+ wrapper: ({ children }) => {children},
+ })
+ return { ...hook, client }
+}
+
+it('deduplicates and chunks deletion at the server cap', async () => {
+ vi.mocked(request).mockImplementation(async (_path, options) => ({ deleted_ids: JSON.parse(String(options?.body)).ids }))
+ const { result, client } = setup()
+ const ids = Array.from({ length: 601 }, (_, index) => index + 1)
+ let deleted: number[] = []
+ await act(async () => { deleted = await result.current.mutateAsync([...ids, 1]) })
+ expect(deleted).toEqual(ids)
+ expect(request).toHaveBeenCalledTimes(2)
+ expect(JSON.parse(String(vi.mocked(request).mock.calls[0][1]?.body)).ids).toHaveLength(500)
+ expect(JSON.parse(String(vi.mocked(request).mock.calls[1][1]?.body)).ids).toHaveLength(101)
+ expect(client.getQueryState(notificationKeys.alertRules)?.isInvalidated).toBe(true)
+ expect(client.getQueryState(notificationKeys.packInstallations)?.isInvalidated).toBe(true)
+})
+
+it('surfaces partial-batch failure and refreshes both rules and pack membership', async () => {
+ vi.mocked(request).mockResolvedValueOnce({ deleted_ids: [1] }).mockRejectedValueOnce(new Error('Second batch failed'))
+ const { result, client } = setup()
+ await act(async () => {
+ await expect(result.current.mutateAsync(Array.from({ length: 501 }, (_, index) => index + 1))).rejects.toThrow('Second batch failed')
+ })
+ await waitFor(() => expect(result.current.isError).toBe(true))
+ expect(toasts.success).not.toHaveBeenCalled()
+ expect(toasts.error).toHaveBeenCalledOnce()
+ expect(client.getQueryState(notificationKeys.alertRules)?.isInvalidated).toBe(true)
+ expect(client.getQueryState(notificationKeys.packInstallations)?.isInvalidated).toBe(true)
+})
diff --git a/web/src/api/hooks/useBulkDeleteAlertRules.ts b/web/src/api/hooks/useBulkDeleteAlertRules.ts
new file mode 100644
index 0000000000..9928fcbc40
--- /dev/null
+++ b/web/src/api/hooks/useBulkDeleteAlertRules.ts
@@ -0,0 +1,29 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { request } from '@/api/client'
+import { invalidateAndBroadcast } from '@/lib/queryBroadcast'
+import { notificationKeys } from './useNotifications'
+import { useMutationToast } from './_toastHelpers'
+
+export function useBulkDeleteAlertRules() {
+ const client = useQueryClient()
+ const { success, error } = useMutationToast()
+ return useMutation({
+ mutationFn: async (ids: number[]) => {
+ const unique = [...new Set(ids)]
+ const deleted: number[] = []
+ for (let offset = 0; offset < unique.length; offset += 500) {
+ const result = await request<{ deleted_ids: number[] }>('/alerts/rules/bulk/delete', {
+ method: 'POST', body: JSON.stringify({ ids: unique.slice(offset, offset + 500) }),
+ })
+ deleted.push(...result.deleted_ids)
+ }
+ return deleted
+ },
+ onSuccess: deleted => success('alertPacks.bulkDeleted', '{{count}} rules deleted', { count: deleted.length }),
+ onError: e => error(e, 'alertPacks.bulkDeleteFailed', 'Deletion did not finish. Refresh the list to review remaining rules before retrying.'),
+ onSettled: () => {
+ invalidateAndBroadcast(client, { queryKey: notificationKeys.alertRules })
+ invalidateAndBroadcast(client, { queryKey: notificationKeys.packInstallations })
+ },
+ })
+}
diff --git a/web/src/api/hooks/useNotifications.ts b/web/src/api/hooks/useNotifications.ts
index 0732814b69..f589afed35 100644
--- a/web/src/api/hooks/useNotifications.ts
+++ b/web/src/api/hooks/useNotifications.ts
@@ -94,6 +94,7 @@ export const notificationKeys = {
alertHistory: (limit: number) => ['alerts', 'history', limit] as const,
alertDetail: (id: number) => ['alerts', 'detail', id] as const,
alertRules: ['alert-rules'] as const,
+ packInstallations: ['alert-pack-installations'] as const,
alertMetrics: ['alert-metrics'] as const,
channels: ['notification-channels'] as const,
eventTypes: ['notification-event-types'] as const,
@@ -562,6 +563,7 @@ export function useSaveAlertRule() {
onSuccess: () => {
invalidateAndBroadcast(qc, { queryKey: notificationKeys.alertRules });
success('toast.alerts.saveRule.success', 'Alert rule saved');
+ invalidateAndBroadcast(qc, { queryKey: notificationKeys.packInstallations });
},
onError: (e) => error(e, 'toast.alerts.saveRule.error', 'Failed to save alert rule'),
});
@@ -576,12 +578,14 @@ export function useDeleteAlertRule() {
onSuccess: () => {
invalidateAndBroadcast(qc, { queryKey: notificationKeys.alertRules });
success('toast.alerts.deleteRule.success', 'Alert rule deleted');
+ invalidateAndBroadcast(qc, { queryKey: notificationKeys.packInstallations });
},
onError: (e) => error(e, 'toast.alerts.deleteRule.error', 'Failed to delete alert rule'),
});
}
export function useToggleAlertRule() {
+ const qc = useQueryClient();
const { success, error } = useMutationToast();
return useOptimisticMutation<
AlertRule,
@@ -597,6 +601,7 @@ export function useToggleAlertRule() {
});
},
queryKeys: [notificationKeys.alertRules],
+ onSettled: () => invalidateAndBroadcast(qc, { queryKey: notificationKeys.packInstallations }),
updater: (prev, { id, enabled }) =>
prev?.map((r) => (r.id === id ? { ...r, enabled } : r)),
broadcast: true,
@@ -633,6 +638,7 @@ export function useBulkEnableRules() {
success('toast.bulk.enable.success', '{{count}} enabled', {
count: res.updated ?? 0,
});
+ invalidateAndBroadcast(qc, { queryKey: notificationKeys.packInstallations });
},
onError: (e) => error(e, 'toast.bulk.enable.error', 'Failed to enable selection'),
});
@@ -653,6 +659,7 @@ export function useBulkDisableRules() {
success('toast.bulk.disable.success', '{{count}} disabled', {
count: res.updated ?? 0,
});
+ invalidateAndBroadcast(qc, { queryKey: notificationKeys.packInstallations });
},
onError: (e) => error(e, 'toast.bulk.disable.error', 'Failed to disable selection'),
});
diff --git a/web/src/api/types.ts b/web/src/api/types.ts
index c66bff34f4..e485a75393 100644
--- a/web/src/api/types.ts
+++ b/web/src/api/types.ts
@@ -718,6 +718,7 @@ export type AlertRuleKind = 'signal' | 'computed_metric'
export type ComputedMetricOp = '>' | '>=' | '<' | '<=' | '=' | '!=' | '%_change_>' | '%_change_<'
export interface AlertRule {
+ channel_ids?: number[] | null
id: number
name: string
description?: string | null
@@ -793,6 +794,7 @@ export interface AlertRule {
}
export interface AlertRuleInput {
+ channel_ids?: number[] | null
name: string
description?: string | null
enabled?: boolean
diff --git a/web/src/components/ai/AIAlertMessageTemplateButton.tsx b/web/src/components/ai/AIAlertMessageTemplateButton.tsx
new file mode 100644
index 0000000000..4e66aec18d
--- /dev/null
+++ b/web/src/components/ai/AIAlertMessageTemplateButton.tsx
@@ -0,0 +1,23 @@
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { HelixMark } from '@/components/branding/HelixMark'
+import { Button, Modal } from '@/components/ui'
+import { AIAlertMessageTemplateSuggestion, type AIAlertMessageTemplateSuggestionProps } from './AIAlertMessageTemplateSuggestion'
+import { withAiFeature } from './withAiFeature'
+
+function InnerButton({ draft, onApplyTemplate, disabled }: AIAlertMessageTemplateSuggestionProps) {
+ const { t } = useTranslation()
+ const [open, setOpen] = useState(false)
+ const title = t('alertPacks.helixMessage', 'Suggest a message for {{name}}', { name: draft.name })
+ return <>
+
+ setOpen(false)} title={title}>
+ {open && { onApplyTemplate(message); setOpen(false) }} />}
+
+ >
+}
+
+export const AIAlertMessageTemplateButton = withAiFeature('alert-message-template-suggestion', InnerButton)
diff --git a/web/src/components/ai/AIAlertPackBuilder.test.tsx b/web/src/components/ai/AIAlertPackBuilder.test.tsx
new file mode 100644
index 0000000000..26a157720b
--- /dev/null
+++ b/web/src/components/ai/AIAlertPackBuilder.test.tsx
@@ -0,0 +1,51 @@
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, expect, it, vi } from 'vitest'
+import type { AlertPack } from '@/api/hooks/useAlertPacks'
+import type { AiStreamEvent } from '@/hooks/useAiStream'
+import { AIAlertPackBuilder } from './AIAlertPackBuilder'
+import '@/i18n'
+
+const mocks = vi.hoisted(() => ({ enabled: false, start: vi.fn(), cancel: vi.fn(), event: undefined as ((event: AiStreamEvent) => void) | undefined }))
+vi.mock('@/hooks/useAiEnabled', () => ({ useAiEnabled: () => mocks.enabled }))
+vi.mock('@/hooks/useAiStream', () => ({
+ useAiStream: (options: { url: string; onEvent: (event: AiStreamEvent) => void }) => {
+ expect(options.url).toBe('/ai/alerts/packs/draft')
+ mocks.event = options.onEvent
+ return { state: 'done', error: null, text: '', start: mocks.start, cancel: mocks.cancel }
+ },
+}))
+const catalog = {
+ id: 'custom', version: 1, name: 'Custom pack', description: 'Build a group.',
+ rules: [{ id: 'battery-low', rule: { name: 'Battery running low' } }, { id: 'charge-complete', rule: { name: 'Charging complete' } }],
+} as AlertPack
+beforeEach(() => { mocks.enabled = false; mocks.event = undefined; vi.clearAllMocks() })
+
+it('does not mount a stream when AI is off', () => {
+ render()
+ expect(screen.queryByTestId('ai-feature-alert-pack-builder-root')).not.toBeInTheDocument()
+ expect(mocks.event).toBeUndefined()
+})
+
+it('requires a goal, accepts only supported proposals and hands off without installing', () => {
+ mocks.enabled = true
+ const apply = vi.fn()
+ const view = render()
+ expect(screen.getByRole('button', { name: /Suggest an alert pack/ })).toBeDisabled()
+ fireEvent.change(screen.getByLabelText('What would you like to keep an eye on?'), { target: { value: 'Battery and charging reminders' } })
+ fireEvent.click(screen.getByRole('button', { name: /Suggest an alert pack/ }))
+ expect(mocks.start).toHaveBeenCalledOnce()
+ act(() => mocks.event?.({ type: 'tool_result', name: 'propose_alert_pack', ok: true, data: {
+ status: 'ok', name: 'Weekend', rationale: 'Useful reminders', template_ids: ['battery-low','invented'],
+ } } as AiStreamEvent))
+ expect(screen.queryByRole('button', { name: 'Review this proposed pack' })).not.toBeInTheDocument()
+ act(() => mocks.event?.({ type: 'tool_result', name: 'propose_alert_pack', ok: true, data: {
+ status: 'ok', name: 'Weekend', rationale: 'Useful reminders', template_ids: ['battery-low','charge-complete'],
+ } } as AiStreamEvent))
+ expect(apply).not.toHaveBeenCalled()
+ fireEvent.click(screen.getByRole('button', { name: 'Review this proposed pack' }))
+ expect(apply).toHaveBeenCalledWith({ ...catalog, name: 'Weekend' })
+ fireEvent.change(screen.getByLabelText('What would you like to keep an eye on?'), { target: { value: 'Different goal' } })
+ expect(screen.queryByRole('button', { name: 'Review this proposed pack' })).not.toBeInTheDocument()
+ view.unmount()
+ expect(mocks.cancel).toHaveBeenCalled()
+})
diff --git a/web/src/components/ai/AIAlertPackBuilder.tsx b/web/src/components/ai/AIAlertPackBuilder.tsx
new file mode 100644
index 0000000000..1b3fc3852b
--- /dev/null
+++ b/web/src/components/ai/AIAlertPackBuilder.tsx
@@ -0,0 +1,81 @@
+import { useCallback, useEffect, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { AIFeatureCard } from '@/components/ai/AIFeatureCard'
+import { withAiFeature } from '@/components/ai/withAiFeature'
+import { Badge, Button, Caption, GlassPanel, PanelTitle, Text, Textarea } from '@/components/ui'
+import { useAiStream, type AiStreamEvent } from '@/hooks/useAiStream'
+import type { AlertPack } from '@/api/hooks/useAlertPacks'
+
+interface Proposal {
+ name: string
+ template_ids: string[]
+ rationale: string
+}
+
+interface Props {
+ catalog: AlertPack
+ onApply: (pack: AlertPack) => void
+}
+
+function Inner({ catalog, onApply }: Props) {
+ const { t } = useTranslation()
+ const [goal, setGoal] = useState('')
+ const [proposal, setProposal] = useState(null)
+ const handleEvent = useCallback((event: AiStreamEvent) => {
+ if (event.type !== 'tool_result' || event.name !== 'propose_alert_pack') return
+ // Invalidate an earlier proposal when a later tool call fails.
+ setProposal(null)
+ if (!event.ok || !event.data || typeof event.data !== 'object') return
+ const data = event.data as Record
+ if (data.status !== 'ok' || typeof data.name !== 'string' || typeof data.rationale !== 'string' || !Array.isArray(data.template_ids)) return
+ const ids = data.template_ids.filter((id): id is string => typeof id === 'string')
+ if (ids.length < 2 || ids.length > catalog.rules.length || ids.length !== data.template_ids.length || new Set(ids).size !== ids.length
+ || ids.some(id => !catalog.rules.some(template => template.id === id))) return
+ setProposal({ name: data.name, rationale: data.rationale, template_ids: ids })
+ }, [catalog.rules])
+ const stream = useAiStream({
+ url: '/ai/alerts/packs/draft',
+ body: { goal },
+ scopeKey: goal,
+ onEvent: handleEvent,
+ })
+ const { cancel } = stream
+ useEffect(() => () => cancel(), [cancel])
+ const busy = stream.state === 'streaming' || stream.state === 'paused-confirm'
+ return (
+ = 5 && goal.trim().length <= 2000 && !busy}
+ stream={{ ...stream, start: () => { setProposal(null); stream.start() } }}
+ inputSlot={
+ )
+}
+
+export const AIAlertPackBuilder = withAiFeature('alert-pack-builder', Inner)
+export default AIAlertPackBuilder
diff --git a/web/src/components/data-display/BulkActionsToolbar.tsx b/web/src/components/data-display/BulkActionsToolbar.tsx
index 092f8ebf0e..edf1e643b6 100644
--- a/web/src/components/data-display/BulkActionsToolbar.tsx
+++ b/web/src/components/data-display/BulkActionsToolbar.tsx
@@ -145,7 +145,7 @@ export function BulkActionsToolbar({
>
{countLabel}
@@ -165,7 +165,7 @@ export function BulkActionsToolbar({
)}
-
+
{items.map((action) => (
}>
Rules
);
+ const action = screen.getByRole('button', { name: 'Install' });
+ expect(getDialog()).toContainElement(action);
+ expect(action.closest('[data-modal-scroll-body]')).toBeNull();
+ expect(action.closest('[data-modal-footer]')).not.toBeNull();
+ action.focus();
+ fireEvent.keyDown(action, { key: 'Tab' });
+ expect(screen.getByRole('button', { name: 'Close' })).toHaveFocus();
+ });
it('renders nothing when open is false', () => {
render(
{}} title="Hidden">
diff --git a/web/src/components/ui/Modal.tsx b/web/src/components/ui/Modal.tsx
index 65ee7389d7..cde892f51f 100644
--- a/web/src/components/ui/Modal.tsx
+++ b/web/src/components/ui/Modal.tsx
@@ -15,6 +15,8 @@ export interface ModalProps extends HTMLAttributes {
*/
size?: 'sm' | 'md' | 'lg' | 'full';
children: ReactNode;
+ /** Persistent actions outside the scrolling dialog body. */
+ footer?: ReactNode;
/**
* Accessible label for the dialog when no `title` is rendered. Required by
* ARIA when the dialog has no visible heading.
@@ -50,7 +52,7 @@ export interface ModalProps extends HTMLAttributes {
* Drawer, and Lightbox cannot drift apart.
*/
export const Modal = forwardRef(
- ({ open, onClose, title, size = 'md', className, children, ariaLabel, ...props }, ref) => {
+ ({ open, onClose, title, size = 'md', className, children, footer, ariaLabel, ...props }, ref) => {
const { t } = useTranslation();
const dialogRef = useRef(null);
const titleId = useId();
@@ -116,6 +118,7 @@ export const Modal = forwardRef(
// Below sm: bottom sheet that fills width, capped to viewport height.
// From sm and up: rounded card, auto height up to 90vh, centered.
'max-h-[100dvh] rounded-none sm:h-auto sm:max-h-[90vh] sm:rounded-lg',
+ footer && 'max-h-[calc(100dvh-var(--shell-chrome-bottom,0px))]',
sizes[size],
className,
)}
@@ -145,6 +148,11 @@ export const Modal = forwardRef(
>
{children}
+ {footer && (
+
+ {footer}
+
+ )}