From e84d72d3b2935b825347edbfd8edfcd0eca6bb40 Mon Sep 17 00:00:00 2001
From: larryro <371767072@qq.com>
Date: Thu, 2 Apr 2026 22:36:01 +0800
Subject: [PATCH 01/13] feat: add file-based provider configuration with SOPS
 secrets

Move LLM provider settings from environment variables to JSON config
files with SOPS-encrypted secrets. Add provider management UI, settings
page with table view, and wire provider resolution through agent config
and workflow engine.
---
 .env.example                                  |   8 +
 compose.yml                                   |   9 +
 examples/agents/chat-agent.json               |   2 +-
 examples/agents/crm-assistant.json            |   3 +-
 examples/agents/file-assistant.json           |   2 +-
 examples/agents/integration-assistant.json    |   2 +-
 examples/agents/web-assistant.json            |   2 +-
 examples/agents/workflow-assistant.json       |   2 +-
 examples/providers/openrouter.json            |  68 +++
 .../src/tale_shared/config/__init__.py        |  16 +-
 .../src/tale_shared/config/base.py            |  51 +-
 .../src/tale_shared/config/providers.py       | 181 +++++++
 .../src/tale_shared/utils/__init__.py         |   2 +
 .../tale_shared/src/tale_shared/utils/sops.py |  32 ++
 services/crawler/Dockerfile                   |   4 +-
 services/platform/Dockerfile                  |   2 +
 .../agents/components/agent-create-dialog.tsx |   2 +-
 .../agents/components/agents-table.tsx        |   8 +-
 .../app/features/agents/hooks/queries.ts      |   4 -
 .../agents/hooks/use-agents-table-config.tsx  |  28 +-
 .../app/features/chat/hooks/queries.ts        |   2 +-
 .../components/settings-navigation.tsx        |   7 +
 .../components/provider-add-dialog.tsx        | 152 ++++++
 .../components/provider-edit-panel.tsx        | 393 ++++++++++++++++
 .../providers/components/providers-page.tsx   | 188 ++++++++
 .../settings/providers/hooks/mutations.ts     |  33 ++
 .../settings/providers/hooks/queries.ts       |  33 ++
 services/platform/app/routeTree.gen.ts        |  22 +
 .../$id/agents/$agentId/instructions.tsx      |  72 +--
 .../dashboard/$id/settings/providers.tsx      |  27 ++
 services/platform/convex/_generated/api.d.ts  |   8 +
 .../files/helpers/analyze_image.ts            |  30 +-
 .../files/helpers/analyze_image_by_url.ts     |  30 +-
 .../agent_tools/files/helpers/analyze_text.ts |  10 +-
 .../agent_tools/files/helpers/vision_agent.ts |  30 +-
 .../convex/agent_tools/files/image_tool.ts    |   3 +-
 .../convex/agent_tools/files/text_tool.ts     |   3 +
 .../agent_tools/human_input/mutations.ts      |  11 +-
 .../convex/agent_tools/location/mutations.ts  |  11 +-
 .../workflows/internal_mutations.ts           |   6 +-
 services/platform/convex/agents/config.ts     |  30 +-
 .../platform/convex/agents/file_actions.ts    |   2 +-
 services/platform/convex/agents/file_utils.ts |   3 +-
 services/platform/convex/agents/queries.ts    |  16 -
 services/platform/convex/agents/start_chat.ts |   7 +-
 .../platform/convex/agents/test_chat.test.ts  |  36 +-
 .../convex/agents/translate_fields.ts         |  36 +-
 .../agents/webhooks/internal_mutations.ts     |   7 +-
 .../platform/convex/conversations/actions.ts  |  27 +-
 .../convex/conversations/improve_message.ts   |  24 +-
 .../convex/lib/agent_chat/internal_actions.ts |  42 +-
 .../lib/attachments/process_attachments.ts    |  32 ++
 .../convex/lib/create_agent_config.ts         |  38 +-
 services/platform/convex/lib/sops.ts          |  57 +++
 .../lib/summarization/auto_summarize.ts       |  12 +-
 .../lib/summarization/internal_actions.ts     |  27 +-
 .../platform/convex/lib/summarize_context.ts  |  41 +-
 .../platform/convex/providers/file_actions.ts | 442 ++++++++++++++++++
 .../platform/convex/providers/file_utils.ts   | 108 +++++
 .../platform/convex/providers/validators.ts   |   5 +
 .../nodes/llm/execute_agent_with_tools.ts     |  15 +-
 .../helpers/nodes/llm/execute_llm_node.ts     |  29 +-
 .../utils/validate_and_normalize_config.ts    |  16 +-
 .../convex/workflows/triggers/actions.ts      |  25 +-
 services/platform/lib/config-watcher.ts       |  12 +-
 .../platform/lib/shared/schemas/agents.ts     |   7 +-
 .../platform/lib/shared/schemas/providers.ts  |  49 ++
 services/platform/messages/en.json            |  52 ++-
 services/rag/Dockerfile                       |   4 +-
 69 files changed, 2355 insertions(+), 345 deletions(-)
 create mode 100644 examples/providers/openrouter.json
 create mode 100644 packages/tale_shared/src/tale_shared/config/providers.py
 create mode 100644 packages/tale_shared/src/tale_shared/utils/sops.py
 create mode 100644 services/platform/app/features/settings/providers/components/provider-add-dialog.tsx
 create mode 100644 services/platform/app/features/settings/providers/components/provider-edit-panel.tsx
 create mode 100644 services/platform/app/features/settings/providers/components/providers-page.tsx
 create mode 100644 services/platform/app/features/settings/providers/hooks/mutations.ts
 create mode 100644 services/platform/app/features/settings/providers/hooks/queries.ts
 create mode 100644 services/platform/app/routes/dashboard/$id/settings/providers.tsx
 create mode 100644 services/platform/convex/lib/sops.ts
 create mode 100644 services/platform/convex/providers/file_actions.ts
 create mode 100644 services/platform/convex/providers/file_utils.ts
 create mode 100644 services/platform/convex/providers/validators.ts
 create mode 100644 services/platform/lib/shared/schemas/providers.ts

diff --git a/.env.example b/.env.example
index a910e741c4..5e2067dc66 100644
--- a/.env.example
+++ b/.env.example
@@ -134,6 +134,14 @@ INSTANCE_SECRET=0516d5cddc8b9bbc01238b8696f13c711983f45f6dc4dbf9dc66ba42fc16f504
 #
 # METRICS_BEARER_TOKEN=
 
+# ============================================================================
+# OPTIONAL: Provider Secrets Encryption
+# ============================================================================
+# Age secret key used by SOPS to decrypt provider secret files (*.secrets.json).
+# Generate a keypair with: age-keygen
+# Only needed if you use file-based provider config with encrypted secrets.
+# SOPS_AGE_KEY=
+
 # ============================================================================
 # OPTIONAL: Operator Service (Browser Automation)
 # ============================================================================
diff --git a/compose.yml b/compose.yml
index acfa226b08..76aa027286 100644
--- a/compose.yml
+++ b/compose.yml
@@ -134,6 +134,9 @@ services:
     env_file:
       - .env
 
+    environment:
+      - SOPS_AGE_KEY=${SOPS_AGE_KEY:-}
+
     # Restart policy
     # Automatically restart the container if it crashes
     restart: unless-stopped
@@ -213,6 +216,9 @@ services:
     env_file:
       - .env
 
+    environment:
+      - SOPS_AGE_KEY=${SOPS_AGE_KEY:-}
+
     # Restart policy
     # Automatically restart the container if it crashes
     restart: unless-stopped
@@ -287,6 +293,9 @@ services:
     env_file:
       - .env
 
+    environment:
+      - SOPS_AGE_KEY=${SOPS_AGE_KEY:-}
+
     # Restart policy
     # Automatically restart the container if it crashes
     restart: unless-stopped
diff --git a/examples/agents/chat-agent.json b/examples/agents/chat-agent.json
index 086b3c9bb3..c8a45a219e 100644
--- a/examples/agents/chat-agent.json
+++ b/examples/agents/chat-agent.json
@@ -17,7 +17,7 @@
     "document_find",
     "document_write"
   ],
-  "modelPreset": "standard",
+  "supportedModels": ["moonshotai/kimi-k2.5", "deepseek/deepseek-v3.2"],
   "knowledgeMode": "tool",
   "includeOrgKnowledge": true,
   "structuredResponsesEnabled": true,
diff --git a/examples/agents/crm-assistant.json b/examples/agents/crm-assistant.json
index 8aff99412b..4f33a7a463 100644
--- a/examples/agents/crm-assistant.json
+++ b/examples/agents/crm-assistant.json
@@ -2,8 +2,7 @@
   "description": "Looks up customer and product information",
   "displayName": "Sales Assistant",
   "maxSteps": 10,
-  "modelId": "anthropic/claude-opus-4.6",
-  "modelPreset": "advanced",
+  "supportedModels": ["anthropic/claude-opus-4.6", "openai/gpt-5.2"],
   "outputReserve": 2048,
   "structuredResponsesEnabled": false,
   "systemInstructions": "You are a CRM assistant specialized in retrieving customer and product data.\n\n**KNOWLEDGE SCOPE**\nYou access ONLY the organization's internal customer and product database.\nFor data from external systems (Shopify, Salesforce, PMS, etc.), the user needs the Integration Assistant — integrations are configured in [Settings > Integrations]({{site_url}}/dashboard/{{organization.id}}/settings/integrations).\n\n**AVAILABLE TOOLS**\n- customer_read: Read customer data (get_by_id, get_by_email, list operations)\n- product_read: Read product data (get_by_id, list operations)\n\n**ACTION-FIRST PRINCIPLE**\nSearch first, but STOP and ask when multiple matches are found.\n\nALWAYS search first:\n• User mentions a name → search by name/email, don't ask for ID\n• User says \"the customer\" → check conversation context for who they mean\n• Partial info given → use it to search, then proceed\n\n**CRITICAL - MULTIPLE MATCHES:**\nWhen you find 2 or more matching records and the user's request implies ONE specific target:\n1. DO NOT pick one arbitrarily or proceed with all\n2. Return the list of matches with minimal distinguishing details (name, status, source) to help the user identify the correct record\n3. Ask the user to clarify which one they mean\n\nDo NOT ask:\n• For IDs when you have a name to search\n• About scope for simple queries (just return results)\n• For confirmation on obvious single-match requests\n\n**CUSTOMER OPERATIONS**\n- get_by_id: Use when you have a specific customer ID\n- get_by_email: Use when searching by email address\n- list: Use for browsing, filtering, or bulk operations\n\n**PRODUCT OPERATIONS**\n- get_by_id: Use when you have a specific product ID\n- list: Use for browsing the catalog or bulk operations\n\n**BEST PRACTICES**\n1. ALWAYS specify the 'fields' parameter to minimize response size\n2. Avoid 'metadata' field unless specifically requested - it can be very large\n3. Use pagination (cursor) for large datasets instead of fetching all at once\n4. Default numItems is 200; reduce if selecting many fields\n5. If hasMore is true, continue with the returned cursor to fetch more data\n\n**FIELD SELECTION GUIDE**\nCustomer common fields: _id, name, email, status, source, locale\nProduct common fields: _id, name, description, price, currency, status, category\n\nHeavy fields (avoid unless needed):\n- Customer: metadata, address\n- Product: metadata, translations\n\n**RESPONSE GUIDELINES**\n- Present data in clear, structured format (tables for lists)\n- Include pagination info when relevant (hasMore, cursor)\n- Summarize large datasets rather than dumping raw data\n- If data not found, say so clearly\n- Never expose internal IDs unless specifically requested",
diff --git a/examples/agents/file-assistant.json b/examples/agents/file-assistant.json
index ba8670cab1..d3ba4f186c 100644
--- a/examples/agents/file-assistant.json
+++ b/examples/agents/file-assistant.json
@@ -15,7 +15,7 @@
     "request_human_input",
     "request_user_location"
   ],
-  "modelPreset": "fast",
+  "supportedModels": ["qwen/qwen3-next-80b-a3b-instruct", "qwen/qwen3.5-35b-a3b"],
   "knowledgeMode": "tool",
   "includeTeamKnowledge": false,
   "maxSteps": 15,
diff --git a/examples/agents/integration-assistant.json b/examples/agents/integration-assistant.json
index dc8a448c6f..2b1e6b7354 100644
--- a/examples/agents/integration-assistant.json
+++ b/examples/agents/integration-assistant.json
@@ -6,6 +6,6 @@
   "maxSteps": 20,
   "timeoutMs": 180000,
   "outputReserve": 2048,
-  "modelPreset": "fast",
+  "supportedModels": ["qwen/qwen3-next-80b-a3b-instruct", "qwen/qwen3.5-35b-a3b"],
   "roleRestriction": "admin_developer"
 }
diff --git a/examples/agents/web-assistant.json b/examples/agents/web-assistant.json
index f10f598f4c..7a8e4bfad3 100644
--- a/examples/agents/web-assistant.json
+++ b/examples/agents/web-assistant.json
@@ -5,7 +5,7 @@
   "toolNames": [
     "web"
   ],
-  "modelPreset": "fast",
+  "supportedModels": ["qwen/qwen3-next-80b-a3b-instruct", "qwen/qwen3.5-35b-a3b"],
   "structuredResponsesEnabled": false,
   "maxSteps": 5,
   "timeoutMs": 300000,
diff --git a/examples/agents/workflow-assistant.json b/examples/agents/workflow-assistant.json
index 52ca9e98fe..20f219478b 100644
--- a/examples/agents/workflow-assistant.json
+++ b/examples/agents/workflow-assistant.json
@@ -12,7 +12,7 @@
     "integration_introspect",
     "run_workflow"
   ],
-  "modelPreset": "advanced",
+  "supportedModels": ["anthropic/claude-opus-4.6", "openai/gpt-5.2"],
   "maxSteps": 30,
   "timeoutMs": 240000,
   "outputReserve": 2048,
diff --git a/examples/providers/openrouter.json b/examples/providers/openrouter.json
new file mode 100644
index 0000000000..5b5d73fde2
--- /dev/null
+++ b/examples/providers/openrouter.json
@@ -0,0 +1,68 @@
+{
+  "displayName": "OpenRouter",
+  "description": "Multi-model AI gateway with access to leading LLM providers",
+  "baseUrl": "https://openrouter.ai/api/v1",
+  "supportsStructuredOutputs": true,
+  "models": [
+    {
+      "id": "moonshotai/kimi-k2.5",
+      "displayName": "Kimi K2.5",
+      "description": "High-performance general-purpose model",
+      "tags": ["chat"],
+      "default": true
+    },
+    {
+      "id": "deepseek/deepseek-v3.2",
+      "displayName": "DeepSeek V3.2",
+      "description": "Strong reasoning and general capabilities",
+      "tags": ["chat"]
+    },
+    {
+      "id": "qwen/qwen3-next-80b-a3b-instruct",
+      "displayName": "Qwen3 Next 80B",
+      "description": "Fast and efficient instruction-following model",
+      "tags": ["chat"]
+    },
+    {
+      "id": "qwen/qwen3.5-35b-a3b",
+      "displayName": "Qwen3.5 35B",
+      "description": "Compact and fast model",
+      "tags": ["chat"]
+    },
+    {
+      "id": "anthropic/claude-opus-4.6",
+      "displayName": "Claude Opus 4.6",
+      "description": "Most capable model for complex reasoning and coding",
+      "tags": ["chat", "vision"]
+    },
+    {
+      "id": "openai/gpt-5.2",
+      "displayName": "GPT-5.2",
+      "description": "OpenAI's latest flagship model",
+      "tags": ["chat", "vision"]
+    },
+    {
+      "id": "qwen/qwen3-vl-32b-instruct",
+      "displayName": "Qwen3 VL 32B",
+      "description": "Vision-language model for image understanding",
+      "tags": ["chat", "vision"]
+    },
+    {
+      "id": "qwen/qwen3-embedding-4b",
+      "displayName": "Qwen3 Embedding 4B",
+      "description": "Text embedding model for semantic search",
+      "tags": ["embedding"],
+      "dimensions": 1536
+    }
+  ],
+  "i18n": {
+    "de": {
+      "description": "Multi-Modell-KI-Gateway mit Zugang zu fuhrenden LLM-Anbietern",
+      "models": {
+        "anthropic/claude-opus-4.6": {
+          "description": "Leistungsstarkstes Modell fur komplexe Aufgaben und Programmierung"
+        }
+      }
+    }
+  }
+}
diff --git a/packages/tale_shared/src/tale_shared/config/__init__.py b/packages/tale_shared/src/tale_shared/config/__init__.py
index a97a0ad919..f66a3c09a1 100644
--- a/packages/tale_shared/src/tale_shared/config/__init__.py
+++ b/packages/tale_shared/src/tale_shared/config/__init__.py
@@ -1,5 +1,19 @@
 """Configuration utilities."""
 
 from .base import BaseServiceSettings
+from .providers import (
+    ProviderConfig,
+    get_chat_model,
+    get_embedding_model,
+    get_vision_model,
+    load_providers,
+)
 
-__all__ = ["BaseServiceSettings"]
+__all__ = [
+    "BaseServiceSettings",
+    "ProviderConfig",
+    "get_chat_model",
+    "get_embedding_model",
+    "get_vision_model",
+    "load_providers",
+]
diff --git a/packages/tale_shared/src/tale_shared/config/base.py b/packages/tale_shared/src/tale_shared/config/base.py
index e1543544d6..e1697422c3 100644
--- a/packages/tale_shared/src/tale_shared/config/base.py
+++ b/packages/tale_shared/src/tale_shared/config/base.py
@@ -4,12 +4,20 @@
 shared patterns across crawler and RAG services.
 """
 
+import logging
 import os
 
 from pydantic_settings import BaseSettings
 
+from tale_shared.config.providers import (
+    get_chat_model as _provider_chat_model,
+    get_embedding_model as _provider_embedding_model,
+    get_vision_model as _provider_vision_model,
+)
 from tale_shared.utils.model_list import get_first_model
 
+logger = logging.getLogger(__name__)
+
 
 class BaseServiceSettings(BaseSettings):
     """Base settings with common patterns for Tale services.
@@ -50,14 +58,26 @@ def get_openai_base_url(self) -> str | None:
         return self.openai_base_url or os.environ.get("OPENAI_BASE_URL")
 
     def get_fast_model(self) -> str:
-        """Get fast LLM model from service-prefixed or generic env var."""
-        model = get_first_model(self.openai_fast_model) or get_first_model(os.environ.get("OPENAI_FAST_MODEL"))
+        """Get fast LLM model from provider files, then env var fallback."""
+        try:
+            _base_url, _api_key, model_id = _provider_chat_model()
+            return model_id
+        except (ValueError, FileNotFoundError):
+            logger.debug("No provider chat model found, falling back to env vars")
+        model = get_first_model(self.openai_fast_model) or get_first_model(
+            os.environ.get("OPENAI_FAST_MODEL")
+        )
         if not model:
             raise ValueError("OPENAI_FAST_MODEL must be set in environment.")
         return model
 
     def get_embedding_model(self) -> str:
-        """Get embedding model from service-prefixed or generic env var."""
+        """Get embedding model from provider files, then env var fallback."""
+        try:
+            _base_url, _api_key, model_id, _dims = _provider_embedding_model()
+            return model_id
+        except (ValueError, FileNotFoundError):
+            logger.debug("No provider embedding model found, falling back to env vars")
         model = get_first_model(self.openai_embedding_model) or get_first_model(
             os.environ.get("OPENAI_EMBEDDING_MODEL")
         )
@@ -66,8 +86,15 @@ def get_embedding_model(self) -> str:
         return model
 
     def get_vision_model(self) -> str:
-        """Get vision model from service-prefixed or generic env var."""
-        model = get_first_model(self.openai_vision_model) or get_first_model(os.environ.get("OPENAI_VISION_MODEL"))
+        """Get vision model from provider files, then env var fallback."""
+        try:
+            _base_url, _api_key, model_id = _provider_vision_model()
+            return model_id
+        except (ValueError, FileNotFoundError):
+            logger.debug("No provider vision model found, falling back to env vars")
+        model = get_first_model(self.openai_vision_model) or get_first_model(
+            os.environ.get("OPENAI_VISION_MODEL")
+        )
         if not model:
             raise ValueError("OPENAI_VISION_MODEL must be set in environment.")
         return model
@@ -81,7 +108,9 @@ def get_embedding_dimensions(self) -> int:
                 try:
                     dims = int(raw)
                 except ValueError:
-                    raise ValueError(f"EMBEDDING_DIMENSIONS must be a valid positive integer, got: {raw!r}") from None
+                    raise ValueError(
+                        f"EMBEDDING_DIMENSIONS must be a valid positive integer, got: {raw!r}"
+                    ) from None
 
         if dims is None:
             raise ValueError(
@@ -92,7 +121,9 @@ def get_embedding_dimensions(self) -> int:
             )
 
         if dims <= 0:
-            raise ValueError(f"EMBEDDING_DIMENSIONS must be a positive integer, got: {dims}")
+            raise ValueError(
+                f"EMBEDDING_DIMENSIONS must be a positive integer, got: {dims}"
+            )
 
         return dims
 
@@ -100,4 +131,8 @@ def get_allowed_origins_list(self) -> list[str]:
         """Parse allowed origins from comma-separated string."""
         if self.allowed_origins == "*":
             return ["*"]
-        return [o for o in (origin.strip() for origin in self.allowed_origins.split(",")) if o]
+        return [
+            o
+            for o in (origin.strip() for origin in self.allowed_origins.split(","))
+            if o
+        ]
diff --git a/packages/tale_shared/src/tale_shared/config/providers.py b/packages/tale_shared/src/tale_shared/config/providers.py
new file mode 100644
index 0000000000..b253ebc9d7
--- /dev/null
+++ b/packages/tale_shared/src/tale_shared/config/providers.py
@@ -0,0 +1,181 @@
+"""Provider configuration reader for file-based LLM provider config."""
+
+import json
+import logging
+import os
+from dataclasses import dataclass, field
+from pathlib import Path
+
+from tale_shared.utils.sops import decrypt_secrets_file
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_CONFIG_DIR = "/app/data"
+
+
+@dataclass
+class ModelConfig:
+    """A single model definition within a provider."""
+
+    id: str
+    display_name: str
+    tags: list[str]
+    description: str = ""
+    default: bool = False
+    dimensions: int | None = None
+
+
+@dataclass
+class ProviderConfig:
+    """A provider loaded from a JSON file, with optional decrypted secrets."""
+
+    name: str
+    display_name: str
+    base_url: str
+    models: list[ModelConfig] = field(default_factory=list)
+    description: str = ""
+    supports_structured_outputs: bool = False
+    api_key: str | None = None
+
+
+def load_providers(config_dir: str | None = None) -> list[ProviderConfig]:
+    """Read all provider JSON files from {config_dir}/providers/.
+
+    Reads *.json (excluding *.secrets.json) and decrypts matching
+    *.secrets.json files via SOPS.
+    """
+    base = Path(config_dir or os.environ.get("CONFIG_DIR", DEFAULT_CONFIG_DIR))
+    providers_dir = base / "providers"
+
+    if not providers_dir.is_dir():
+        logger.warning("Providers directory not found: %s", providers_dir)
+        return []
+
+    providers: list[ProviderConfig] = []
+
+    for json_file in sorted(providers_dir.glob("*.json")):
+        if json_file.name.endswith(".secrets.json"):
+            continue
+
+        try:
+            with open(json_file) as f:
+                data = json.load(f)
+        except (json.JSONDecodeError, OSError) as exc:
+            logger.error("Failed to read provider file %s: %s", json_file, exc)
+            continue
+
+        provider_name = json_file.stem
+
+        # Load secrets if present
+        api_key: str | None = None
+        secrets_file = json_file.with_suffix("").with_suffix(".secrets.json")
+        if secrets_file.exists():
+            try:
+                secrets = decrypt_secrets_file(secrets_file)
+                api_key = secrets.get("apiKey")
+            except (RuntimeError, OSError) as exc:
+                logger.error("Failed to decrypt secrets for %s: %s", provider_name, exc)
+
+        models = []
+        for m in data.get("models", []):
+            models.append(
+                ModelConfig(
+                    id=m["id"],
+                    display_name=m.get("displayName", m["id"]),
+                    tags=m.get("tags", []),
+                    description=m.get("description", ""),
+                    default=m.get("default", False),
+                    dimensions=m.get("dimensions"),
+                )
+            )
+
+        providers.append(
+            ProviderConfig(
+                name=provider_name,
+                display_name=data.get("displayName", provider_name),
+                base_url=data.get("baseUrl", ""),
+                models=models,
+                description=data.get("description", ""),
+                supports_structured_outputs=data.get(
+                    "supportsStructuredOutputs", False
+                ),
+                api_key=api_key,
+            )
+        )
+
+    return providers
+
+
+def _find_model(
+    providers: list[ProviderConfig], tag: str, *, prefer_default: bool = False
+) -> tuple[ProviderConfig, ModelConfig] | None:
+    """Find a model by tag across all providers.
+
+    If prefer_default is True, return the first model marked default that
+    also has the given tag, falling back to the first model with the tag.
+    """
+    first_match: tuple[ProviderConfig, ModelConfig] | None = None
+
+    for provider in providers:
+        for model in provider.models:
+            if tag in model.tags:
+                if first_match is None:
+                    first_match = (provider, model)
+                if prefer_default and model.default:
+                    return (provider, model)
+                if not prefer_default and first_match is not None:
+                    return first_match
+
+    return first_match
+
+
+def get_chat_model(
+    config_dir: str | None = None,
+) -> tuple[str, str, str]:
+    """Return (base_url, api_key, model_id) for the default chat model.
+
+    Finds the first model marked default that has a "chat" tag,
+    or falls back to the first model with a "chat" tag.
+    """
+    providers = load_providers(config_dir)
+    match = _find_model(providers, "chat", prefer_default=True)
+    if match is None:
+        raise ValueError("No chat model found in provider configuration files.")
+
+    provider, model = match
+    api_key = provider.api_key or ""
+    return (provider.base_url, api_key, model.id)
+
+
+def get_embedding_model(
+    config_dir: str | None = None,
+) -> tuple[str, str, str, int]:
+    """Return (base_url, api_key, model_id, dimensions) for the embedding model."""
+    providers = load_providers(config_dir)
+    match = _find_model(providers, "embedding")
+    if match is None:
+        raise ValueError("No embedding model found in provider configuration files.")
+
+    provider, model = match
+    api_key = provider.api_key or ""
+    dims = model.dimensions
+    if dims is None:
+        raise ValueError(
+            f"Embedding model {model.id} does not specify dimensions. "
+            "Add a 'dimensions' field to the model definition."
+        )
+    return (provider.base_url, api_key, model.id, dims)
+
+
+def get_vision_model(
+    config_dir: str | None = None,
+) -> tuple[str, str, str]:
+    """Return (base_url, api_key, model_id) for the vision model."""
+    providers = load_providers(config_dir)
+    match = _find_model(providers, "vision")
+    if match is None:
+        raise ValueError("No vision model found in provider configuration files.")
+
+    provider, model = match
+    api_key = provider.api_key or ""
+    return (provider.base_url, api_key, model.id)
diff --git a/packages/tale_shared/src/tale_shared/utils/__init__.py b/packages/tale_shared/src/tale_shared/utils/__init__.py
index f784994785..94fd35e335 100644
--- a/packages/tale_shared/src/tale_shared/utils/__init__.py
+++ b/packages/tale_shared/src/tale_shared/utils/__init__.py
@@ -2,10 +2,12 @@
 
 from .hashing import compute_content_hash, compute_file_hash
 from .model_list import get_first_model, get_first_model_or_raise, parse_model_list
+from .sops import decrypt_secrets_file
 
 __all__ = [
     "compute_content_hash",
     "compute_file_hash",
+    "decrypt_secrets_file",
     "get_first_model",
     "get_first_model_or_raise",
     "parse_model_list",
diff --git a/packages/tale_shared/src/tale_shared/utils/sops.py b/packages/tale_shared/src/tale_shared/utils/sops.py
new file mode 100644
index 0000000000..399bcdc6f1
--- /dev/null
+++ b/packages/tale_shared/src/tale_shared/utils/sops.py
@@ -0,0 +1,32 @@
+"""SOPS decrypt utility for encrypted JSON files."""
+
+import json
+import subprocess
+from pathlib import Path
+
+_cache: dict[str, tuple[dict, float]] = {}
+
+
+def decrypt_secrets_file(file_path: str | Path) -> dict:
+    """Decrypt a SOPS-encrypted JSON file. Caches by mtime."""
+    path = Path(file_path)
+    mtime = path.stat().st_mtime
+    cached = _cache.get(str(path))
+    if cached and cached[1] == mtime:
+        return cached[0]
+
+    result = subprocess.run(
+        ["sops", "-d", "--output-type", "json", str(path)],
+        capture_output=True,
+        text=True,
+        timeout=10,
+    )
+    if result.returncode != 0:
+        raise RuntimeError(
+            f"Failed to decrypt {path}: {result.stderr}. "
+            "Ensure sops is installed and SOPS_AGE_KEY is set."
+        )
+
+    data = json.loads(result.stdout)
+    _cache[str(path)] = (data, mtime)
+    return data
diff --git a/services/crawler/Dockerfile b/services/crawler/Dockerfile
index 0b8c87901b..7ecfe0674d 100644
--- a/services/crawler/Dockerfile
+++ b/services/crawler/Dockerfile
@@ -15,6 +15,7 @@ WORKDIR /app
 # - fonts-noto-core: Latin (English, German, French, Spanish, etc.) and Cyrillic (Russian)
 # - fonts-noto-color-emoji: Emoji support
 # - fonts-dejavu-core: Fallback fonts with broad Unicode coverage
+ARG SOPS_VERSION=3.9.4
 RUN apt-get update && apt-get install -y \
     tini \
     curl \
@@ -45,7 +46,8 @@ RUN apt-get update && apt-get install -y \
     libxrandr2 \
     xdg-utils \
     && rm -rf /var/lib/apt/lists/* \
-    && fc-cache -fv
+    && fc-cache -fv \
+    && curl -fsSL "https://github.com/getsops/sops/releases/download/v${SOPS_VERSION}/sops-v${SOPS_VERSION}.linux.amd64" -o /usr/local/bin/sops && chmod +x /usr/local/bin/sops
 
 # Install uv for faster package management
 COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
diff --git a/services/platform/Dockerfile b/services/platform/Dockerfile
index 235b241bca..5e07d2991c 100644
--- a/services/platform/Dockerfile
+++ b/services/platform/Dockerfile
@@ -125,9 +125,11 @@ RUN ln -s /usr/local/bin/bun /usr/local/bin/bunx
 
 WORKDIR /app
 
+ARG SOPS_VERSION=3.9.4
 RUN apt-get update && apt-get install -y --no-install-recommends \
     curl tini postgresql-client ca-certificates \
     && rm -rf /var/lib/apt/lists/* \
+    && curl -fsSL "https://github.com/getsops/sops/releases/download/v${SOPS_VERSION}/sops-v${SOPS_VERSION}.linux.amd64" -o /usr/local/bin/sops && chmod +x /usr/local/bin/sops \
     && mkdir -p /usr/local/share/ca-certificates \
     && chmod 755 /usr/local/share/ca-certificates \
     && chmod +x /convex/convex-local-backend /convex/generate_key \
diff --git a/services/platform/app/features/agents/components/agent-create-dialog.tsx b/services/platform/app/features/agents/components/agent-create-dialog.tsx
index bc3513e308..0a04f2933e 100644
--- a/services/platform/app/features/agents/components/agent-create-dialog.tsx
+++ b/services/platform/app/features/agents/components/agent-create-dialog.tsx
@@ -82,7 +82,7 @@ export function CreateAgentDialog({
           displayName: data.displayName,
           description: data.description,
           systemInstructions: 'You are a helpful assistant.',
-          modelPreset: 'standard',
+          supportedModels: ['moonshotai/kimi-k2.5'],
         },
       });
       toast({
diff --git a/services/platform/app/features/agents/components/agents-table.tsx b/services/platform/app/features/agents/components/agents-table.tsx
index 7a01520bc4..ede1c617ba 100644
--- a/services/platform/app/features/agents/components/agents-table.tsx
+++ b/services/platform/app/features/agents/components/agents-table.tsx
@@ -12,7 +12,7 @@ import { useListPage } from '@/app/hooks/use-list-page';
 import { useTeamFilter } from '@/app/hooks/use-team-filter';
 import { useT } from '@/lib/i18n/client';
 
-import { useListAgents, useModelPresets } from '../hooks/queries';
+import { useListAgents } from '../hooks/queries';
 import { useAgentsTableConfig } from '../hooks/use-agents-table-config';
 import { AgentsActionMenu } from './agents-action-menu';
 
@@ -20,7 +20,7 @@ export interface AgentRow {
   name: string;
   displayName: string;
   description?: string;
-  modelPreset?: string;
+  supportedModels?: string[];
   toolNames?: string[];
   visibleInChat?: boolean;
   roleRestriction?: string;
@@ -37,7 +37,6 @@ export function AgentsTable({ organizationId }: AgentsTableProps) {
   const { teams } = useTeamFilter();
   const navigate = useNavigate();
   const queryClient = useQueryClient();
-  const { data: modelPresets } = useModelPresets();
   const { agents: rawAgents, isLoading } = useListAgents('default');
 
   const agents = useMemo(() => {
@@ -49,7 +48,7 @@ export function AgentsTable({ organizationId }: AgentsTableProps) {
           name: a.name,
           displayName: a.displayName,
           description: a.description,
-          modelPreset: a.modelPreset,
+          supportedModels: a.supportedModels,
           toolNames: a.toolNames,
           visibleInChat: a.visibleInChat,
           roleRestriction: a.roleRestriction,
@@ -76,7 +75,6 @@ export function AgentsTable({ organizationId }: AgentsTableProps) {
   const { columns, searchPlaceholder, stickyLayout, pageSize } =
     useAgentsTableConfig({
       teamNameMap,
-      modelPresets,
       onDuplicated: invalidateAgents,
       onDeleted: invalidateAgents,
     });
diff --git a/services/platform/app/features/agents/hooks/queries.ts b/services/platform/app/features/agents/hooks/queries.ts
index 37895bf0a7..fd808aff4f 100644
--- a/services/platform/app/features/agents/hooks/queries.ts
+++ b/services/platform/app/features/agents/hooks/queries.ts
@@ -101,10 +101,6 @@ export function useAvailableWorkflows(organizationId: string) {
   };
 }
 
-export function useModelPresets() {
-  return useConvexQuery(api.agents.queries.getModelPresets);
-}
-
 export type AgentWebhook = ConvexItemOf<
   typeof api.agents.webhooks.queries.getWebhooks
 >;
diff --git a/services/platform/app/features/agents/hooks/use-agents-table-config.tsx b/services/platform/app/features/agents/hooks/use-agents-table-config.tsx
index d579614cf0..54b9c25433 100644
--- a/services/platform/app/features/agents/hooks/use-agents-table-config.tsx
+++ b/services/platform/app/features/agents/hooks/use-agents-table-config.tsx
@@ -8,7 +8,6 @@ import { Badge } from '@/app/components/ui/feedback/badge';
 import { HStack } from '@/app/components/ui/layout/layout';
 import { Text } from '@/app/components/ui/typography/text';
 import { useT } from '@/lib/i18n/client';
-import { isKeyOf } from '@/lib/utils/type-guards';
 
 import type { AgentRow } from '../components/agents-table';
 
@@ -23,13 +22,11 @@ interface AgentsTableConfig {
 
 interface AgentsTableConfigOptions {
   teamNameMap: Map<string, string>;
-  modelPresets: Record<string, string[]> | undefined;
   onDuplicated?: () => void;
   onDeleted?: () => void;
 }
 
 export function useAgentsTableConfig({
-  modelPresets,
   onDuplicated,
   onDeleted,
 }: AgentsTableConfigOptions): AgentsTableConfig {
@@ -49,26 +46,13 @@ export function useAgentsTableConfig({
         size: 250,
       },
       {
-        id: 'modelPreset',
-        header: t('agents.columns.modelPreset'),
+        id: 'model',
+        header: t('agents.columns.model'),
         meta: { skeleton: { type: 'badge' } },
         cell: ({ row }) => {
-          const preset = row.original.modelPreset ?? 'standard';
-          const presetLabel = t(`agents.form.modelPresets.${preset}`);
-          const modelName =
-            modelPresets && isKeyOf(preset, modelPresets)
-              ? modelPresets[preset]?.[0]
-              : undefined;
-          return (
-            <Badge variant="outline">
-              {presetLabel}
-              {modelName && (
-                <span className="text-muted-foreground/60 ml-1">
-                  {modelName}
-                </span>
-              )}
-            </Badge>
-          );
+          const model = row.original.supportedModels?.[0];
+          if (!model) return null;
+          return <Badge variant="outline">{model}</Badge>;
         },
         size: 200,
       },
@@ -103,7 +87,7 @@ export function useAgentsTableConfig({
         size: 80,
       },
     ],
-    [t, modelPresets, onDuplicated, onDeleted],
+    [t, onDuplicated, onDeleted],
   );
 
   return {
diff --git a/services/platform/app/features/chat/hooks/queries.ts b/services/platform/app/features/chat/hooks/queries.ts
index 6e1d8a5e92..80f88e8230 100644
--- a/services/platform/app/features/chat/hooks/queries.ts
+++ b/services/platform/app/features/chat/hooks/queries.ts
@@ -63,7 +63,7 @@ export interface ChatAgent {
   displayName: string;
   description?: string;
   visibleInChat?: boolean;
-  modelPreset?: string;
+  supportedModels?: string[];
   toolNames?: string[];
   roleRestriction?: string;
   conversationStarters?: string[];
diff --git a/services/platform/app/features/settings/components/settings-navigation.tsx b/services/platform/app/features/settings/components/settings-navigation.tsx
index 5ac57c4c4e..b7d0be20bf 100644
--- a/services/platform/app/features/settings/components/settings-navigation.tsx
+++ b/services/platform/app/features/settings/components/settings-navigation.tsx
@@ -15,6 +15,7 @@ type SettingsLabelKey =
   | 'organization'
   | 'teams'
   | 'integrations'
+  | 'providers'
   | 'apiKeys'
   | 'branding'
   | 'account';
@@ -45,6 +46,12 @@ export function SettingsNavigation({
       href: `/dashboard/${organizationId}/settings/integrations`,
       can: ['read', 'developerSettings'],
     },
+    {
+      labelKey: 'providers',
+      label: t('providers'),
+      href: `/dashboard/${organizationId}/settings/providers`,
+      can: ['read', 'developerSettings'],
+    },
     {
       labelKey: 'apiKeys',
       label: t('apiKeys'),
diff --git a/services/platform/app/features/settings/providers/components/provider-add-dialog.tsx b/services/platform/app/features/settings/providers/components/provider-add-dialog.tsx
new file mode 100644
index 0000000000..957eb562b0
--- /dev/null
+++ b/services/platform/app/features/settings/providers/components/provider-add-dialog.tsx
@@ -0,0 +1,152 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useMemo } from 'react';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod/v4';
+
+import { FormDialog } from '@/app/components/ui/dialog/form-dialog';
+import { Input } from '@/app/components/ui/forms/input';
+import { Text } from '@/app/components/ui/typography/text';
+import { toast } from '@/app/hooks/use-toast';
+import { useT } from '@/lib/i18n/client';
+
+import { useSaveProvider } from '../hooks/mutations';
+
+type FormData = {
+  name: string;
+  displayName: string;
+  baseUrl: string;
+};
+
+interface ProviderAddDialogProps {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  organizationId: string;
+}
+
+export function ProviderAddDialog({
+  open,
+  onOpenChange,
+  organizationId: _organizationId,
+}: ProviderAddDialogProps) {
+  const { t } = useT('settings');
+  const { t: tCommon } = useT('common');
+  const { mutateAsync: saveProvider } = useSaveProvider();
+
+  const formSchema = useMemo(
+    () =>
+      z.object({
+        name: z
+          .string()
+          .min(
+            1,
+            tCommon('validation.required', {
+              field: t('providers.name'),
+            }),
+          )
+          .regex(/^[a-z][a-z0-9-]*$/, t('providers.namePatternError')),
+        displayName: z.string().min(
+          1,
+          tCommon('validation.required', {
+            field: t('providers.displayName'),
+          }),
+        ),
+        baseUrl: z.string().url(
+          tCommon('validation.required', {
+            field: t('providers.baseUrl'),
+          }),
+        ),
+      }),
+    [t, tCommon],
+  );
+
+  const {
+    register,
+    handleSubmit,
+    formState: { isSubmitting, errors },
+    reset,
+  } = useForm<FormData>({
+    resolver: zodResolver(formSchema),
+    defaultValues: {
+      name: '',
+      displayName: '',
+      baseUrl: '',
+    },
+  });
+
+  const onSubmit = async (data: FormData) => {
+    try {
+      await saveProvider({
+        orgSlug: 'default',
+        providerName: data.name,
+        config: {
+          displayName: data.displayName,
+          baseUrl: data.baseUrl,
+          models: [
+            {
+              id: `${data.name}/default`,
+              displayName: 'Default',
+              tags: ['chat'],
+              default: true,
+            },
+          ],
+        },
+      });
+      toast({
+        title: t('providers.created'),
+        variant: 'success',
+      });
+      reset();
+      onOpenChange(false);
+    } catch (error) {
+      console.error(error);
+      toast({
+        title: t('providers.createFailed'),
+        variant: 'destructive',
+      });
+    }
+  };
+
+  return (
+    <FormDialog
+      open={open}
+      onOpenChange={(isOpen) => {
+        if (!isOpen) reset();
+        onOpenChange(isOpen);
+      }}
+      title={t('providers.addProvider')}
+      submitText={tCommon('actions.create')}
+      submittingText={tCommon('actions.adding')}
+      isSubmitting={isSubmitting}
+      onSubmit={handleSubmit(onSubmit)}
+    >
+      <Input
+        id="name"
+        label={t('providers.name')}
+        {...register('name')}
+        placeholder={t('providers.namePlaceholder')}
+        errorMessage={errors.name?.message}
+      />
+      <Text variant="caption" className="-mt-2">
+        {t('providers.nameHelp')}
+      </Text>
+
+      <Input
+        id="displayName"
+        label={t('providers.displayName')}
+        {...register('displayName')}
+        placeholder={t('providers.displayNamePlaceholder')}
+        errorMessage={errors.displayName?.message}
+      />
+
+      <Input
+        id="baseUrl"
+        label={t('providers.baseUrl')}
+        {...register('baseUrl')}
+        placeholder={t('providers.baseUrlPlaceholder')}
+        errorMessage={errors.baseUrl?.message}
+      />
+    </FormDialog>
+  );
+}
diff --git a/services/platform/app/features/settings/providers/components/provider-edit-panel.tsx b/services/platform/app/features/settings/providers/components/provider-edit-panel.tsx
new file mode 100644
index 0000000000..8ca315b1f9
--- /dev/null
+++ b/services/platform/app/features/settings/providers/components/provider-edit-panel.tsx
@@ -0,0 +1,393 @@
+'use client';
+
+import { KeyRound, Plus, Trash2 } from 'lucide-react';
+import { useCallback, useEffect, useState } from 'react';
+
+import { ConfirmDialog } from '@/app/components/ui/dialog/confirm-dialog';
+import { Badge } from '@/app/components/ui/feedback/badge';
+import { Skeleton } from '@/app/components/ui/feedback/skeleton';
+import { Checkbox } from '@/app/components/ui/forms/checkbox';
+import { CheckboxGroup } from '@/app/components/ui/forms/checkbox-group';
+import { Input } from '@/app/components/ui/forms/input';
+import { Textarea } from '@/app/components/ui/forms/textarea';
+import { HStack, Stack } from '@/app/components/ui/layout/layout';
+import { SectionHeader } from '@/app/components/ui/layout/section-header';
+import { Separator } from '@/app/components/ui/layout/separator';
+import { Sheet } from '@/app/components/ui/overlays/sheet';
+import { Button } from '@/app/components/ui/primitives/button';
+import { IconButton } from '@/app/components/ui/primitives/icon-button';
+import { Text } from '@/app/components/ui/typography/text';
+import { toast } from '@/app/hooks/use-toast';
+import { useT } from '@/lib/i18n/client';
+
+import {
+  useDeleteProvider,
+  useSaveProvider,
+  useSaveProviderSecret,
+} from '../hooks/mutations';
+import { useHasProviderSecret, useReadProvider } from '../hooks/queries';
+
+interface ProviderEditPanelProps {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  providerName: string;
+  organizationId: string;
+}
+
+interface ModelFormData {
+  id: string;
+  displayName: string;
+  description: string;
+  tags: string[];
+  default: boolean;
+  dimensions: string;
+}
+
+const TAG_OPTIONS = [
+  { value: 'chat', label: 'Chat' },
+  { value: 'vision', label: 'Vision' },
+  { value: 'embedding', label: 'Embedding' },
+];
+
+function emptyModel(): ModelFormData {
+  return {
+    id: '',
+    displayName: '',
+    description: '',
+    tags: ['chat'],
+    default: false,
+    dimensions: '',
+  };
+}
+
+export function ProviderEditPanel({
+  open,
+  onOpenChange,
+  providerName,
+  organizationId: _organizationId,
+}: ProviderEditPanelProps) {
+  const { t } = useT('settings');
+  const { t: tCommon } = useT('common');
+
+  const { data: readResult, isLoading } = useReadProvider(
+    'default',
+    providerName,
+  );
+  const { data: hasSecret } = useHasProviderSecret('default', providerName);
+  const { mutateAsync: saveProvider, isPending: isSaving } = useSaveProvider();
+  const { mutateAsync: deleteProvider, isPending: isDeleting } =
+    useDeleteProvider();
+  const { mutateAsync: saveSecret, isPending: isSavingSecret } =
+    useSaveProviderSecret();
+
+  const [displayName, setDisplayName] = useState('');
+  const [description, setDescription] = useState('');
+  const [baseUrl, setBaseUrl] = useState('');
+  const [models, setModels] = useState<ModelFormData[]>([]);
+  const [apiKey, setApiKey] = useState('');
+  const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
+
+  // Populate form when provider data loads
+  useEffect(() => {
+    if (readResult && 'config' in readResult && readResult.ok) {
+      const config = readResult.config as {
+        displayName: string;
+        description?: string;
+        baseUrl: string;
+        models: Array<{
+          id: string;
+          displayName: string;
+          description?: string;
+          tags: string[];
+          default?: boolean;
+          dimensions?: number;
+        }>;
+      };
+      setDisplayName(config.displayName);
+      setDescription(config.description ?? '');
+      setBaseUrl(config.baseUrl);
+      setModels(
+        config.models.map((m) => ({
+          id: m.id,
+          displayName: m.displayName,
+          description: m.description ?? '',
+          tags: [...m.tags],
+          default: m.default ?? false,
+          dimensions: m.dimensions != null ? String(m.dimensions) : '',
+        })),
+      );
+      setApiKey('');
+    }
+  }, [readResult]);
+
+  const handleSave = useCallback(async () => {
+    try {
+      const config = {
+        displayName,
+        description: description || undefined,
+        baseUrl,
+        models: models.map((m) => ({
+          id: m.id,
+          displayName: m.displayName,
+          description: m.description || undefined,
+          tags: m.tags,
+          default: m.default || undefined,
+          dimensions:
+            m.tags.includes('embedding') && m.dimensions
+              ? Number(m.dimensions)
+              : undefined,
+        })),
+      };
+      await saveProvider({
+        orgSlug: 'default',
+        providerName,
+        config,
+      });
+      toast({ title: t('providers.saved'), variant: 'success' });
+    } catch (error) {
+      console.error(error);
+      toast({ title: t('providers.saveFailed'), variant: 'destructive' });
+    }
+  }, [
+    displayName,
+    description,
+    baseUrl,
+    models,
+    saveProvider,
+    providerName,
+    t,
+  ]);
+
+  const handleDelete = useCallback(async () => {
+    try {
+      await deleteProvider({ orgSlug: 'default', providerName });
+      toast({ title: t('providers.deleted'), variant: 'success' });
+      setDeleteConfirmOpen(false);
+      onOpenChange(false);
+    } catch (error) {
+      console.error(error);
+      toast({ title: t('providers.deleteFailed'), variant: 'destructive' });
+    }
+  }, [deleteProvider, providerName, t, onOpenChange]);
+
+  const handleSaveSecret = useCallback(async () => {
+    if (!apiKey.trim()) return;
+    try {
+      await saveSecret({ orgSlug: 'default', providerName, apiKey });
+      toast({ title: t('providers.secretSaved'), variant: 'success' });
+      setApiKey('');
+    } catch (error) {
+      console.error(error);
+      toast({
+        title: t('providers.secretSaveFailed'),
+        variant: 'destructive',
+      });
+    }
+  }, [apiKey, saveSecret, providerName, t]);
+
+  const updateModel = (index: number, updates: Partial<ModelFormData>) => {
+    setModels((prev) =>
+      prev.map((m, i) => (i === index ? { ...m, ...updates } : m)),
+    );
+  };
+
+  const removeModel = (index: number) => {
+    setModels((prev) => prev.filter((_, i) => i !== index));
+  };
+
+  const addModel = () => {
+    setModels((prev) => [...prev, emptyModel()]);
+  };
+
+  return (
+    <>
+      <Sheet
+        open={open}
+        onOpenChange={onOpenChange}
+        title={t('providers.editProvider')}
+        side="right"
+        size="md"
+        className="overflow-y-auto"
+      >
+        {isLoading ? (
+          <Stack gap={4} className="pt-4">
+            <Skeleton className="h-8 w-full" />
+            <Skeleton className="h-8 w-full" />
+            <Skeleton className="h-8 w-full" />
+          </Stack>
+        ) : (
+          <Stack gap={6} className="pt-2 pb-24">
+            {/* General section */}
+            <Stack gap={4}>
+              <SectionHeader title={t('providers.general')} as="h3" size="sm" />
+              <Input
+                label={t('providers.displayName')}
+                value={displayName}
+                onChange={(e) => setDisplayName(e.target.value)}
+                placeholder={t('providers.displayNamePlaceholder')}
+              />
+              <Textarea
+                label={t('providers.description_field')}
+                value={description}
+                onChange={(e) => setDescription(e.target.value)}
+                placeholder={t('providers.descriptionPlaceholder')}
+                rows={2}
+              />
+              <Input
+                label={t('providers.baseUrl')}
+                value={baseUrl}
+                onChange={(e) => setBaseUrl(e.target.value)}
+                placeholder={t('providers.baseUrlPlaceholder')}
+              />
+            </Stack>
+
+            <Separator />
+
+            {/* API Key section */}
+            <Stack gap={3}>
+              <SectionHeader title={t('providers.apiKey')} as="h3" size="sm" />
+              <HStack gap={2}>
+                <KeyRound className="text-muted-foreground size-4" />
+                <Badge variant={hasSecret ? 'green' : 'orange'} dot>
+                  {hasSecret
+                    ? t('providers.apiKeyConfigured')
+                    : t('providers.apiKeyNotConfigured')}
+                </Badge>
+              </HStack>
+              <HStack gap={2} align="end">
+                <Input
+                  type="password"
+                  value={apiKey}
+                  onChange={(e) => setApiKey(e.target.value)}
+                  placeholder={t('providers.apiKeyPlaceholder')}
+                  wrapperClassName="flex-1"
+                />
+                <Button
+                  onClick={handleSaveSecret}
+                  disabled={!apiKey.trim() || isSavingSecret}
+                  variant="secondary"
+                >
+                  {isSavingSecret
+                    ? tCommon('actions.saving')
+                    : tCommon('actions.save')}
+                </Button>
+              </HStack>
+            </Stack>
+
+            <Separator />
+
+            {/* Models section */}
+            <Stack gap={4}>
+              <SectionHeader
+                title={t('providers.models')}
+                as="h3"
+                size="sm"
+                action={
+                  <Button variant="secondary" size="sm" onClick={addModel}>
+                    <Plus className="mr-1 size-3.5" />
+                    {t('providers.addModel')}
+                  </Button>
+                }
+              />
+
+              {models.map((model, index) => (
+                <Stack key={index} gap={3} className="rounded-lg border p-4">
+                  <HStack justify="between" align="start">
+                    <Text className="text-sm font-medium">
+                      {model.displayName || model.id || `#${index + 1}`}
+                    </Text>
+                    <IconButton
+                      icon={Trash2}
+                      aria-label={t('providers.removeModel')}
+                      className="text-muted-foreground hover:text-destructive size-7"
+                      onClick={() => removeModel(index)}
+                    />
+                  </HStack>
+                  <Input
+                    label={t('providers.modelId')}
+                    value={model.id}
+                    onChange={(e) => updateModel(index, { id: e.target.value })}
+                    placeholder={t('providers.modelIdPlaceholder')}
+                    size="sm"
+                  />
+                  <Input
+                    label={t('providers.displayName')}
+                    value={model.displayName}
+                    onChange={(e) =>
+                      updateModel(index, { displayName: e.target.value })
+                    }
+                    placeholder={t('providers.modelDisplayNamePlaceholder')}
+                    size="sm"
+                  />
+                  <Input
+                    label={t('providers.description_field')}
+                    value={model.description}
+                    onChange={(e) =>
+                      updateModel(index, { description: e.target.value })
+                    }
+                    placeholder={t('providers.modelDescriptionPlaceholder')}
+                    size="sm"
+                  />
+                  <CheckboxGroup
+                    label={t('providers.tags')}
+                    options={TAG_OPTIONS}
+                    value={model.tags}
+                    onValueChange={(tags) => updateModel(index, { tags })}
+                    columns={2}
+                  />
+                  <Checkbox
+                    label={t('providers.defaultModel')}
+                    checked={model.default}
+                    onCheckedChange={(checked) =>
+                      updateModel(index, { default: checked === true })
+                    }
+                  />
+                  {model.tags.includes('embedding') && (
+                    <Input
+                      label={t('providers.dimensions')}
+                      type="number"
+                      value={model.dimensions}
+                      onChange={(e) =>
+                        updateModel(index, { dimensions: e.target.value })
+                      }
+                      placeholder={t('providers.dimensionsPlaceholder')}
+                      size="sm"
+                    />
+                  )}
+                </Stack>
+              ))}
+            </Stack>
+
+            <Separator />
+
+            {/* Actions */}
+            <HStack justify="between">
+              <Button
+                variant="destructive"
+                onClick={() => setDeleteConfirmOpen(true)}
+              >
+                <Trash2 className="mr-1.5 size-4" />
+                {t('providers.deleteProvider')}
+              </Button>
+              <Button onClick={handleSave} disabled={isSaving}>
+                {isSaving ? tCommon('actions.saving') : tCommon('actions.save')}
+              </Button>
+            </HStack>
+          </Stack>
+        )}
+      </Sheet>
+
+      <ConfirmDialog
+        open={deleteConfirmOpen}
+        onOpenChange={setDeleteConfirmOpen}
+        title={t('providers.deleteProvider')}
+        description={t('providers.deleteConfirm')}
+        variant="destructive"
+        confirmText={tCommon('actions.delete')}
+        loadingText={tCommon('actions.deleting')}
+        isLoading={isDeleting}
+        onConfirm={handleDelete}
+      />
+    </>
+  );
+}
diff --git a/services/platform/app/features/settings/providers/components/providers-page.tsx b/services/platform/app/features/settings/providers/components/providers-page.tsx
new file mode 100644
index 0000000000..69741540a4
--- /dev/null
+++ b/services/platform/app/features/settings/providers/components/providers-page.tsx
@@ -0,0 +1,188 @@
+'use client';
+
+import { KeyRound, Plus } from 'lucide-react';
+import { useState } from 'react';
+
+import { Badge } from '@/app/components/ui/feedback/badge';
+import { Skeleton } from '@/app/components/ui/feedback/skeleton';
+import { Card } from '@/app/components/ui/layout/card';
+import { Grid, HStack, Stack } from '@/app/components/ui/layout/layout';
+import { SectionHeader } from '@/app/components/ui/layout/section-header';
+import { Button } from '@/app/components/ui/primitives/button';
+import { Text } from '@/app/components/ui/typography/text';
+import { useT } from '@/lib/i18n/client';
+
+import { useHasProviderSecret, useListProviders } from '../hooks/queries';
+import { ProviderAddDialog } from './provider-add-dialog';
+import { ProviderEditPanel } from './provider-edit-panel';
+
+interface ProvidersPageProps {
+  organizationId: string;
+}
+
+interface ProviderListItem {
+  name: string;
+  displayName?: string;
+  description?: string;
+  baseUrl?: string;
+  modelCount?: number;
+  models?: Array<{ id: string; displayName?: string; tags?: string[] }>;
+}
+
+function ProviderCardSkeleton() {
+  return (
+    <Card className="flex flex-col justify-between" contentClassName="p-5">
+      <Stack gap={3}>
+        <HStack justify="between" align="start">
+          <Skeleton className="h-5 w-24" />
+          <Skeleton className="h-5 w-16 rounded-full" />
+        </HStack>
+        <Stack gap={1}>
+          <Skeleton className="h-4 w-full" />
+          <Skeleton className="h-4 w-3/4" />
+        </Stack>
+        <Skeleton className="h-4 w-20" />
+      </Stack>
+    </Card>
+  );
+}
+
+function ProvidersSkeleton() {
+  return (
+    <Stack gap={0}>
+      <HStack justify="between" align="start" className="pb-3">
+        <Stack gap={1}>
+          <Skeleton className="h-7 w-32" />
+          <Skeleton className="h-5 w-64" />
+        </Stack>
+        <Skeleton className="h-9 w-40" />
+      </HStack>
+      <Grid cols={1} md={2} lg={3}>
+        {Array.from({ length: 3 }).map((_, i) => (
+          <ProviderCardSkeleton key={i} />
+        ))}
+      </Grid>
+    </Stack>
+  );
+}
+
+export function ProvidersPage({ organizationId }: ProvidersPageProps) {
+  const { t } = useT('settings');
+  const { providers, isLoading } = useListProviders('default');
+  // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- providers shape is guaranteed by file_actions.listProviders contract
+  const providerList = (providers ?? []) as ProviderListItem[];
+  const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
+  const [addDialogOpen, setAddDialogOpen] = useState(false);
+
+  return (
+    <Stack gap={6}>
+      {isLoading ? (
+        <ProvidersSkeleton />
+      ) : (
+        <Stack gap={4}>
+          <SectionHeader
+            title={t('providers.title')}
+            description={t('providers.description')}
+            action={
+              <Button onClick={() => setAddDialogOpen(true)}>
+                <Plus className="mr-1.5 size-4" />
+                {t('providers.addProvider')}
+              </Button>
+            }
+          />
+
+          {providerList.length === 0 ? (
+            <Card contentClassName="p-8 text-center">
+              <Stack gap={2}>
+                <Text variant="muted">{t('providers.noProviders')}</Text>
+                <Text variant="muted" className="text-sm">
+                  {t('providers.noProvidersDescription')}
+                </Text>
+              </Stack>
+            </Card>
+          ) : (
+            <Grid cols={1} md={2} lg={3}>
+              {providerList.map((provider) => (
+                <button
+                  key={provider.name}
+                  type="button"
+                  className="text-left"
+                  onClick={() => setSelectedProvider(provider.name)}
+                >
+                  <Card
+                    className="hover:border-primary/50 h-full cursor-pointer transition-colors"
+                    contentClassName="p-5"
+                  >
+                    <Stack gap={3}>
+                      <HStack justify="between" align="start">
+                        <Text className="font-semibold">
+                          {provider.displayName ?? provider.name}
+                        </Text>
+                        <Badge variant="outline">
+                          {t('providers.modelCount', {
+                            count: provider.modelCount ?? 0,
+                          })}
+                        </Badge>
+                      </HStack>
+                      {provider.description && (
+                        <Text variant="muted" className="line-clamp-2 text-sm">
+                          {provider.description}
+                        </Text>
+                      )}
+                      {provider.baseUrl && (
+                        <Text variant="muted" className="truncate text-xs">
+                          {provider.baseUrl}
+                        </Text>
+                      )}
+                      <HStack gap={1}>
+                        <KeyRound className="text-muted-foreground size-3.5" />
+                        <ProviderSecretStatus providerName={provider.name} />
+                      </HStack>
+                    </Stack>
+                  </Card>
+                </button>
+              ))}
+            </Grid>
+          )}
+        </Stack>
+      )}
+
+      {selectedProvider && (
+        <ProviderEditPanel
+          open
+          onOpenChange={(open) => {
+            if (!open) setSelectedProvider(null);
+          }}
+          providerName={selectedProvider}
+          organizationId={organizationId}
+        />
+      )}
+
+      <ProviderAddDialog
+        open={addDialogOpen}
+        onOpenChange={setAddDialogOpen}
+        organizationId={organizationId}
+      />
+    </Stack>
+  );
+}
+
+function ProviderSecretStatus({ providerName }: { providerName: string }) {
+  const { t } = useT('settings');
+  const { data: hasSecret, isLoading } = useHasProviderSecret(
+    'default',
+    providerName,
+  );
+
+  if (isLoading) {
+    return <Skeleton className="h-3.5 w-20" />;
+  }
+
+  return (
+    <Text variant="muted" className="text-xs">
+      {hasSecret
+        ? t('providers.apiKeyConfigured')
+        : t('providers.apiKeyNotConfigured')}
+    </Text>
+  );
+}
diff --git a/services/platform/app/features/settings/providers/hooks/mutations.ts b/services/platform/app/features/settings/providers/hooks/mutations.ts
new file mode 100644
index 0000000000..caa6d62976
--- /dev/null
+++ b/services/platform/app/features/settings/providers/hooks/mutations.ts
@@ -0,0 +1,33 @@
+import { useQueryClient } from '@tanstack/react-query';
+
+import { useConvexAction } from '@/app/hooks/use-convex-action';
+import { api } from '@/convex/_generated/api';
+
+function useInvalidateProviders() {
+  const queryClient = useQueryClient();
+  return (orgSlug: string) =>
+    queryClient.invalidateQueries({
+      queryKey: ['config', 'providers', orgSlug],
+    });
+}
+
+export function useSaveProvider() {
+  const invalidate = useInvalidateProviders();
+  return useConvexAction(api.providers.file_actions.saveProvider, {
+    onSuccess: (_data, variables) => invalidate(variables.orgSlug),
+  });
+}
+
+export function useDeleteProvider() {
+  const invalidate = useInvalidateProviders();
+  return useConvexAction(api.providers.file_actions.deleteProvider, {
+    onSuccess: (_data, variables) => invalidate(variables.orgSlug),
+  });
+}
+
+export function useSaveProviderSecret() {
+  const invalidate = useInvalidateProviders();
+  return useConvexAction(api.providers.file_actions.saveProviderSecret, {
+    onSuccess: (_data, variables) => invalidate(variables.orgSlug),
+  });
+}
diff --git a/services/platform/app/features/settings/providers/hooks/queries.ts b/services/platform/app/features/settings/providers/hooks/queries.ts
new file mode 100644
index 0000000000..3eca32e76b
--- /dev/null
+++ b/services/platform/app/features/settings/providers/hooks/queries.ts
@@ -0,0 +1,33 @@
+import { configKeys } from '@/app/hooks/config-query-keys';
+import { useActionQuery } from '@/app/hooks/use-action-query';
+import { api } from '@/convex/_generated/api';
+
+// ---------------------------------------------------------------------------
+// Action-based hooks (filesystem reads — cached via TanStack Query,
+// invalidated by SSE file events and mutation onSuccess)
+// ---------------------------------------------------------------------------
+
+export function useListProviders(orgSlug: string) {
+  const { data, isLoading, error, refetch } = useActionQuery(
+    configKeys.list('providers', orgSlug),
+    api.providers.file_actions.listProviders,
+    { orgSlug },
+  );
+  return { providers: data ?? [], isLoading, error, refetch };
+}
+
+export function useReadProvider(orgSlug: string, providerName: string) {
+  return useActionQuery(
+    configKeys.detail('providers', orgSlug, providerName),
+    api.providers.file_actions.readProvider,
+    { orgSlug, providerName },
+  );
+}
+
+export function useHasProviderSecret(orgSlug: string, providerName: string) {
+  return useActionQuery(
+    ['config', 'providers', orgSlug, providerName, 'secret'],
+    api.providers.file_actions.hasProviderSecret,
+    { orgSlug, providerName },
+  );
+}
diff --git a/services/platform/app/routeTree.gen.ts b/services/platform/app/routeTree.gen.ts
index 93c8b631e5..5d3017cc7a 100644
--- a/services/platform/app/routeTree.gen.ts
+++ b/services/platform/app/routeTree.gen.ts
@@ -32,6 +32,7 @@ import { Route as DashboardIdChatIndexRouteImport } from './routes/dashboard/$id
 import { Route as DashboardIdAutomationsIndexRouteImport } from './routes/dashboard/$id/automations/index'
 import { Route as DashboardIdAgentsIndexRouteImport } from './routes/dashboard/$id/agents/index'
 import { Route as DashboardIdSettingsTeamsRouteImport } from './routes/dashboard/$id/settings/teams'
+import { Route as DashboardIdSettingsProvidersRouteImport } from './routes/dashboard/$id/settings/providers'
 import { Route as DashboardIdSettingsOrganizationRouteImport } from './routes/dashboard/$id/settings/organization'
 import { Route as DashboardIdSettingsLogsRouteImport } from './routes/dashboard/$id/settings/logs'
 import { Route as DashboardIdSettingsIntegrationsRouteImport } from './routes/dashboard/$id/settings/integrations'
@@ -177,6 +178,12 @@ const DashboardIdSettingsTeamsRoute =
     path: '/teams',
     getParentRoute: () => DashboardIdSettingsRoute,
   } as any)
+const DashboardIdSettingsProvidersRoute =
+  DashboardIdSettingsProvidersRouteImport.update({
+    id: '/providers',
+    path: '/providers',
+    getParentRoute: () => DashboardIdSettingsRoute,
+  } as any)
 const DashboardIdSettingsOrganizationRoute =
   DashboardIdSettingsOrganizationRouteImport.update({
     id: '/organization',
@@ -365,6 +372,7 @@ export interface FileRoutesByFullPath {
   '/dashboard/$id/settings/integrations': typeof DashboardIdSettingsIntegrationsRoute
   '/dashboard/$id/settings/logs': typeof DashboardIdSettingsLogsRoute
   '/dashboard/$id/settings/organization': typeof DashboardIdSettingsOrganizationRoute
+  '/dashboard/$id/settings/providers': typeof DashboardIdSettingsProvidersRoute
   '/dashboard/$id/settings/teams': typeof DashboardIdSettingsTeamsRoute
   '/dashboard/$id/agents/': typeof DashboardIdAgentsIndexRoute
   '/dashboard/$id/automations/': typeof DashboardIdAutomationsIndexRoute
@@ -407,6 +415,7 @@ export interface FileRoutesByTo {
   '/dashboard/$id/settings/integrations': typeof DashboardIdSettingsIntegrationsRoute
   '/dashboard/$id/settings/logs': typeof DashboardIdSettingsLogsRoute
   '/dashboard/$id/settings/organization': typeof DashboardIdSettingsOrganizationRoute
+  '/dashboard/$id/settings/providers': typeof DashboardIdSettingsProvidersRoute
   '/dashboard/$id/settings/teams': typeof DashboardIdSettingsTeamsRoute
   '/dashboard/$id/agents': typeof DashboardIdAgentsIndexRoute
   '/dashboard/$id/automations': typeof DashboardIdAutomationsIndexRoute
@@ -459,6 +468,7 @@ export interface FileRoutesById {
   '/dashboard/$id/settings/integrations': typeof DashboardIdSettingsIntegrationsRoute
   '/dashboard/$id/settings/logs': typeof DashboardIdSettingsLogsRoute
   '/dashboard/$id/settings/organization': typeof DashboardIdSettingsOrganizationRoute
+  '/dashboard/$id/settings/providers': typeof DashboardIdSettingsProvidersRoute
   '/dashboard/$id/settings/teams': typeof DashboardIdSettingsTeamsRoute
   '/dashboard/$id/agents/': typeof DashboardIdAgentsIndexRoute
   '/dashboard/$id/automations/': typeof DashboardIdAutomationsIndexRoute
@@ -510,6 +520,7 @@ export interface FileRouteTypes {
     | '/dashboard/$id/settings/integrations'
     | '/dashboard/$id/settings/logs'
     | '/dashboard/$id/settings/organization'
+    | '/dashboard/$id/settings/providers'
     | '/dashboard/$id/settings/teams'
     | '/dashboard/$id/agents/'
     | '/dashboard/$id/automations/'
@@ -552,6 +563,7 @@ export interface FileRouteTypes {
     | '/dashboard/$id/settings/integrations'
     | '/dashboard/$id/settings/logs'
     | '/dashboard/$id/settings/organization'
+    | '/dashboard/$id/settings/providers'
     | '/dashboard/$id/settings/teams'
     | '/dashboard/$id/agents'
     | '/dashboard/$id/automations'
@@ -603,6 +615,7 @@ export interface FileRouteTypes {
     | '/dashboard/$id/settings/integrations'
     | '/dashboard/$id/settings/logs'
     | '/dashboard/$id/settings/organization'
+    | '/dashboard/$id/settings/providers'
     | '/dashboard/$id/settings/teams'
     | '/dashboard/$id/agents/'
     | '/dashboard/$id/automations/'
@@ -791,6 +804,13 @@ declare module '@tanstack/react-router' {
       preLoaderRoute: typeof DashboardIdSettingsTeamsRouteImport
       parentRoute: typeof DashboardIdSettingsRoute
     }
+    '/dashboard/$id/settings/providers': {
+      id: '/dashboard/$id/settings/providers'
+      path: '/providers'
+      fullPath: '/dashboard/$id/settings/providers'
+      preLoaderRoute: typeof DashboardIdSettingsProvidersRouteImport
+      parentRoute: typeof DashboardIdSettingsRoute
+    }
     '/dashboard/$id/settings/organization': {
       id: '/dashboard/$id/settings/organization'
       path: '/organization'
@@ -1124,6 +1144,7 @@ interface DashboardIdSettingsRouteChildren {
   DashboardIdSettingsIntegrationsRoute: typeof DashboardIdSettingsIntegrationsRoute
   DashboardIdSettingsLogsRoute: typeof DashboardIdSettingsLogsRoute
   DashboardIdSettingsOrganizationRoute: typeof DashboardIdSettingsOrganizationRoute
+  DashboardIdSettingsProvidersRoute: typeof DashboardIdSettingsProvidersRoute
   DashboardIdSettingsTeamsRoute: typeof DashboardIdSettingsTeamsRoute
   DashboardIdSettingsIndexRoute: typeof DashboardIdSettingsIndexRoute
 }
@@ -1136,6 +1157,7 @@ const DashboardIdSettingsRouteChildren: DashboardIdSettingsRouteChildren = {
   DashboardIdSettingsIntegrationsRoute: DashboardIdSettingsIntegrationsRoute,
   DashboardIdSettingsLogsRoute: DashboardIdSettingsLogsRoute,
   DashboardIdSettingsOrganizationRoute: DashboardIdSettingsOrganizationRoute,
+  DashboardIdSettingsProvidersRoute: DashboardIdSettingsProvidersRoute,
   DashboardIdSettingsTeamsRoute: DashboardIdSettingsTeamsRoute,
   DashboardIdSettingsIndexRoute: DashboardIdSettingsIndexRoute,
 }
diff --git a/services/platform/app/routes/dashboard/$id/agents/$agentId/instructions.tsx b/services/platform/app/routes/dashboard/$id/agents/$agentId/instructions.tsx
index 4a7ef01c25..c4b65fcb4f 100644
--- a/services/platform/app/routes/dashboard/$id/agents/$agentId/instructions.tsx
+++ b/services/platform/app/routes/dashboard/$id/agents/$agentId/instructions.tsx
@@ -1,19 +1,12 @@
-import { convexQuery } from '@convex-dev/react-query';
 import { createFileRoute } from '@tanstack/react-router';
-import { useMemo } from 'react';
-
-import type { ModelPreset } from '@/lib/shared/schemas/agents';
 
 import { ContentArea } from '@/app/components/layout/content-area';
 import { CodeBlock } from '@/app/components/ui/data-display/code-block';
-import { Select } from '@/app/components/ui/forms/select';
 import { Switch } from '@/app/components/ui/forms/switch';
 import { Textarea } from '@/app/components/ui/forms/textarea';
 import { PageSection } from '@/app/components/ui/layout/page-section';
 import { CollapsibleDetails } from '@/app/components/ui/navigation/collapsible-details';
-import { useModelPresets } from '@/app/features/agents/hooks/queries';
 import { useAgentConfig } from '@/app/features/agents/hooks/use-agent-config-context';
-import { api } from '@/convex/_generated/api';
 import { SUPPORTED_TEMPLATE_VARIABLES } from '@/convex/lib/agent_response/resolve_template_variables';
 import { STRUCTURED_RESPONSE_INSTRUCTIONS } from '@/convex/lib/agent_response/structured_response_instructions';
 import { useT } from '@/lib/i18n/client';
@@ -25,59 +18,13 @@ export const Route = createFileRoute(
   head: () => ({
     meta: seo('agentInstructions'),
   }),
-  loader: ({ context }) => {
-    void context.queryClient.prefetchQuery(
-      convexQuery(api.agents.queries.getModelPresets, {}),
-    );
-  },
   component: InstructionsTab,
 });
 
-const PRESET_KEYS = ['fast', 'standard', 'advanced'] as const;
-
-function derivePresetFromModelId(
-  modelId: string,
-  presets: Record<string, string[]>,
-): ModelPreset {
-  for (const key of PRESET_KEYS) {
-    if (presets[key]?.includes(modelId)) return key;
-  }
-  return 'standard';
-}
-
-function getDefaultModelId(
-  preset: string,
-  presets: Record<string, string[]> | undefined,
-): string {
-  return presets?.[preset]?.[0] ?? '';
-}
-
 function InstructionsTab() {
   const { t } = useT('settings');
   const { config, updateConfig } = useAgentConfig();
 
-  const { data: modelPresets } = useModelPresets();
-
-  const modelOptions = useMemo(() => {
-    if (!modelPresets) return [];
-    const options: { value: string; label: string }[] = [];
-    for (const key of PRESET_KEYS) {
-      const models = modelPresets[key] ?? [];
-      const presetLabel = t(`agents.form.modelPresets.${key}`);
-      for (const model of models) {
-        options.push({
-          value: model,
-          label: `${model} (${presetLabel})`,
-        });
-      }
-    }
-    return options;
-  }, [modelPresets, t]);
-
-  const currentModelId =
-    config.modelId ??
-    getDefaultModelId(config.modelPreset ?? 'standard', modelPresets);
-
   const structuredResponsesEnabled = config.structuredResponsesEnabled ?? true;
 
   return (
@@ -119,18 +66,13 @@ function InstructionsTab() {
         title={t('agents.form.sectionModel')}
         description={t('agents.form.sectionModelDescription')}
       >
-        <Select
-          options={modelOptions}
-          label={t('agents.form.model')}
-          value={currentModelId}
-          onValueChange={(val) => {
-            const modelPreset = modelPresets
-              ? derivePresetFromModelId(val, modelPresets)
-              : 'standard';
-            updateConfig({ modelId: val, modelPreset });
-          }}
-          required
-        />
+        <ul className="space-y-1 text-sm">
+          {(config.supportedModels ?? []).map((model) => (
+            <li key={model}>
+              <code className="bg-muted rounded px-1.5 py-0.5">{model}</code>
+            </li>
+          ))}
+        </ul>
       </PageSection>
 
       <PageSection
diff --git a/services/platform/app/routes/dashboard/$id/settings/providers.tsx b/services/platform/app/routes/dashboard/$id/settings/providers.tsx
new file mode 100644
index 0000000000..44926b7e5d
--- /dev/null
+++ b/services/platform/app/routes/dashboard/$id/settings/providers.tsx
@@ -0,0 +1,27 @@
+import { createFileRoute } from '@tanstack/react-router';
+
+import { AccessDenied } from '@/app/components/layout/access-denied';
+import { ProvidersPage } from '@/app/features/settings/providers/components/providers-page';
+import { useAbility } from '@/app/hooks/use-ability';
+import { useT } from '@/lib/i18n/client';
+import { seo } from '@/lib/utils/seo';
+
+export const Route = createFileRoute('/dashboard/$id/settings/providers')({
+  head: () => ({
+    meta: seo('providers'),
+  }),
+  component: ProvidersRoute,
+});
+
+function ProvidersRoute() {
+  const { id: organizationId } = Route.useParams();
+  const { t } = useT('accessDenied');
+
+  const ability = useAbility();
+
+  if (ability.cannot('read', 'developerSettings')) {
+    return <AccessDenied message={t('integrations')} />;
+  }
+
+  return <ProvidersPage organizationId={organizationId} />;
+}
diff --git a/services/platform/convex/_generated/api.d.ts b/services/platform/convex/_generated/api.d.ts
index 2cd041c52c..396eb83c29 100644
--- a/services/platform/convex/_generated/api.d.ts
+++ b/services/platform/convex/_generated/api.d.ts
@@ -392,6 +392,7 @@ import type * as lib_rls_validators from "../lib/rls/validators.js";
 import type * as lib_rls_wrappers_with_organization_rls from "../lib/rls/wrappers/with_organization_rls.js";
 import type * as lib_rls_wrappers_with_resource_rls from "../lib/rls/wrappers/with_resource_rls.js";
 import type * as lib_shared_schemas_utils_json_value from "../lib/shared/schemas/utils/json_value.js";
+import type * as lib_sops from "../lib/sops.js";
 import type * as lib_summarization_auto_summarize from "../lib/summarization/auto_summarize.js";
 import type * as lib_summarization_index from "../lib/summarization/index.js";
 import type * as lib_summarization_internal_actions from "../lib/summarization/internal_actions.js";
@@ -503,6 +504,9 @@ import type * as products_update_product from "../products/update_product.js";
 import type * as products_update_products from "../products/update_products.js";
 import type * as products_upsert_product_translation from "../products/upsert_product_translation.js";
 import type * as products_validators from "../products/validators.js";
+import type * as providers_file_actions from "../providers/file_actions.js";
+import type * as providers_file_utils from "../providers/file_utils.js";
+import type * as providers_validators from "../providers/validators.js";
 import type * as sso_providers_actions from "../sso_providers/actions.js";
 import type * as sso_providers_create_user_session from "../sso_providers/create_user_session.js";
 import type * as sso_providers_entra_id_adapter from "../sso_providers/entra_id/adapter.js";
@@ -1224,6 +1228,7 @@ declare const fullApi: ApiFromModules<{
   "lib/rls/wrappers/with_organization_rls": typeof lib_rls_wrappers_with_organization_rls;
   "lib/rls/wrappers/with_resource_rls": typeof lib_rls_wrappers_with_resource_rls;
   "lib/shared/schemas/utils/json_value": typeof lib_shared_schemas_utils_json_value;
+  "lib/sops": typeof lib_sops;
   "lib/summarization/auto_summarize": typeof lib_summarization_auto_summarize;
   "lib/summarization/index": typeof lib_summarization_index;
   "lib/summarization/internal_actions": typeof lib_summarization_internal_actions;
@@ -1335,6 +1340,9 @@ declare const fullApi: ApiFromModules<{
   "products/update_products": typeof products_update_products;
   "products/upsert_product_translation": typeof products_upsert_product_translation;
   "products/validators": typeof products_validators;
+  "providers/file_actions": typeof providers_file_actions;
+  "providers/file_utils": typeof providers_file_utils;
+  "providers/validators": typeof providers_validators;
   "sso_providers/actions": typeof sso_providers_actions;
   "sso_providers/create_user_session": typeof sso_providers_create_user_session;
   "sso_providers/entra_id/adapter": typeof sso_providers_entra_id_adapter;
diff --git a/services/platform/convex/agent_tools/files/helpers/analyze_image.ts b/services/platform/convex/agent_tools/files/helpers/analyze_image.ts
index 589cc78190..d8aeea0cc6 100644
--- a/services/platform/convex/agent_tools/files/helpers/analyze_image.ts
+++ b/services/platform/convex/agent_tools/files/helpers/analyze_image.ts
@@ -10,13 +10,15 @@
  * This file runs in V8 runtime (no 'use node' directive) for better compatibility.
  */
 
+import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
+
 import type { Id } from '../../../_generated/dataModel';
 import type { ActionCtx } from '../../../_generated/server';
 
-import { components } from '../../../_generated/api';
+import { components, internal } from '../../../_generated/api';
 import { imageAnalysisCache } from '../../../lib/action_cache';
 import { createDebugLog } from '../../../lib/debug_log';
-import { getVisionModel, createVisionAgent } from './vision_agent';
+import { createVisionAgent } from './vision_agent';
 
 const debugLog = createDebugLog('DEBUG_IMAGE_ANALYSIS', '[ImageAnalysis]');
 
@@ -49,10 +51,30 @@ export async function analyzeImage(
   params: AnalyzeImageParams,
 ): Promise<AnalyzeImageResult> {
   const { fileId, question, fileName } = params;
-  const visionModelId = getVisionModel();
 
   debugLog('analyzeImage starting', { fileId, question, fileName });
 
+  // Resolve vision model from provider files
+  // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- resolveModelByTag returns v.any() but shape is guaranteed by provider file_actions contract
+  const modelData = (await ctx.runAction(
+    internal.providers.file_actions.resolveModelByTag,
+    { tag: 'vision' },
+  )) as {
+    providerName: string;
+    baseUrl: string;
+    apiKey: string;
+    modelId: string;
+    supportsStructuredOutputs: boolean;
+  };
+  const providerInstance = createOpenAICompatible({
+    name: modelData.providerName,
+    baseURL: modelData.baseUrl,
+    apiKey: modelData.apiKey,
+    supportsStructuredOutputs: modelData.supportsStructuredOutputs,
+  });
+  const languageModel = providerInstance.chatModel(modelData.modelId);
+  const visionModelId = modelData.modelId;
+
   try {
     // Get the image blob from storage
     const imageBlob = await ctx.storage.get(fileId);
@@ -88,7 +110,7 @@ export async function analyzeImage(
       mimeType,
     });
 
-    const visionAgent = createVisionAgent();
+    const visionAgent = createVisionAgent(languageModel);
 
     // Create a temporary thread for this analysis
     const thread = await ctx.runMutation(
diff --git a/services/platform/convex/agent_tools/files/helpers/analyze_image_by_url.ts b/services/platform/convex/agent_tools/files/helpers/analyze_image_by_url.ts
index b6193c3121..03d1a03f3a 100644
--- a/services/platform/convex/agent_tools/files/helpers/analyze_image_by_url.ts
+++ b/services/platform/convex/agent_tools/files/helpers/analyze_image_by_url.ts
@@ -4,12 +4,14 @@
  * Helper for analyzing images by URL using the vision model.
  */
 
+import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
+
 import type { ActionCtx } from '../../../_generated/server';
 import type { AnalyzeImageResult } from './analyze_image';
 
-import { components } from '../../../_generated/api';
+import { components, internal } from '../../../_generated/api';
 import { createDebugLog } from '../../../lib/debug_log';
-import { getVisionModel, createVisionAgent } from './vision_agent';
+import { createVisionAgent } from './vision_agent';
 
 const debugLog = createDebugLog('DEBUG_IMAGE_ANALYSIS', '[ImageAnalysis]');
 
@@ -30,16 +32,36 @@ export async function analyzeImageByUrl(
   params: AnalyzeImageByUrlParams,
 ): Promise<AnalyzeImageResult> {
   const { imageUrl, question } = params;
-  const visionModelId = getVisionModel();
 
   debugLog('analyzeImageByUrl starting', {
     imageUrl: imageUrl.slice(0, 100),
     question,
   });
 
+  // Resolve vision model from provider files
+  // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- resolveModelByTag returns v.any() but shape is guaranteed by provider file_actions contract
+  const modelData = (await ctx.runAction(
+    internal.providers.file_actions.resolveModelByTag,
+    { tag: 'vision' },
+  )) as {
+    providerName: string;
+    baseUrl: string;
+    apiKey: string;
+    modelId: string;
+    supportsStructuredOutputs: boolean;
+  };
+  const providerInstance = createOpenAICompatible({
+    name: modelData.providerName,
+    baseURL: modelData.baseUrl,
+    apiKey: modelData.apiKey,
+    supportsStructuredOutputs: modelData.supportsStructuredOutputs,
+  });
+  const languageModel = providerInstance.chatModel(modelData.modelId);
+  const visionModelId = modelData.modelId;
+
   try {
     // Create a vision agent
-    const visionAgent = createVisionAgent();
+    const visionAgent = createVisionAgent(languageModel);
 
     // Create a temporary thread for this analysis
     const thread = await ctx.runMutation(
diff --git a/services/platform/convex/agent_tools/files/helpers/analyze_text.ts b/services/platform/convex/agent_tools/files/helpers/analyze_text.ts
index b41cc4cb76..c28dc57143 100644
--- a/services/platform/convex/agent_tools/files/helpers/analyze_text.ts
+++ b/services/platform/convex/agent_tools/files/helpers/analyze_text.ts
@@ -5,13 +5,14 @@
  * Uses Agent framework with saveMessages: 'none' to avoid creating visible thread messages.
  */
 
+import type { LanguageModelV3 } from '@ai-sdk/provider';
+
 import { Agent } from '@convex-dev/agent';
 
 import type { ActionCtx } from '../../../_generated/server';
 
 import { components } from '../../../_generated/api';
 import { createDebugLog } from '../../../lib/debug_log';
-import { openai } from '../../../lib/openai_provider';
 import { toId } from '../../../lib/type_cast_helpers';
 
 const debugLog = createDebugLog('DEBUG_TEXT_ANALYSIS', '[TextAnalysis]');
@@ -63,6 +64,7 @@ export interface AnalyzeTextParams {
   filename: string;
   userInput: string;
   model: string;
+  languageModel: LanguageModelV3;
 }
 
 export interface AnalyzeTextUsage {
@@ -155,12 +157,12 @@ Guidelines:
 - If the text doesn't contain relevant information, say so clearly
 - For large texts processed in chunks, focus on the most relevant parts`;
 
-function createTextAnalysisAgent(model: string): Agent {
+function createTextAnalysisAgent(languageModel: LanguageModelV3): Agent {
   const instructions = `${TEXT_ANALYSIS_INSTRUCTIONS}\n\nIf you use any tools, you must always conclude by producing a final assistant message with the answer.`;
 
   return new Agent(components.agent, {
     name: 'text-analyzer',
-    languageModel: openai.chatModel(model),
+    languageModel,
     instructions,
   });
 }
@@ -344,7 +346,7 @@ export async function analyzeTextContent(
 
     debugLog('analyzeTextContent decoded', { charCount, lineCount, encoding });
 
-    const agent = createTextAnalysisAgent(model);
+    const agent = createTextAnalysisAgent(params.languageModel);
 
     // For smaller content, process in one pass
     if (charCount <= LLM_CHUNK_SIZE) {
diff --git a/services/platform/convex/agent_tools/files/helpers/vision_agent.ts b/services/platform/convex/agent_tools/files/helpers/vision_agent.ts
index fbf842b85d..0451ced153 100644
--- a/services/platform/convex/agent_tools/files/helpers/vision_agent.ts
+++ b/services/platform/convex/agent_tools/files/helpers/vision_agent.ts
@@ -5,40 +5,24 @@
  * Provides vision model configuration and agent creation.
  */
 
+import type { LanguageModelV3 } from '@ai-sdk/provider';
+
 import { Agent } from '@convex-dev/agent';
 
-import { getFirstModel } from '../../../../lib/shared/utils/model-list';
 import { components } from '../../../_generated/api';
-import { getDefaultModel } from '../../../lib/agent_runtime_config';
 import { createDebugLog } from '../../../lib/debug_log';
-import { getEnvOptional } from '../../../lib/get_or_throw';
-import { openai } from '../../../lib/openai_provider';
 
 const debugLog = createDebugLog('DEBUG_IMAGE_ANALYSIS', '[VisionAgent]');
 
 /**
- * Get the vision model ID from environment variables
- */
-export function getVisionModel(): string {
-  const visionModel = getFirstModel(process.env.OPENAI_VISION_MODEL);
-  if (visionModel) {
-    return visionModel;
-  }
-  return getDefaultModel();
-}
-
-/**
- * Creates a vision agent for image analysis
+ * Creates a vision agent for image analysis.
+ * The caller must resolve the language model via ctx.runAction before calling this.
  */
-export function createVisionAgent(): Agent {
-  const visionModelId = getVisionModel();
-  debugLog('Creating vision agent', {
-    model: visionModelId,
-    baseUrl: getEnvOptional('OPENAI_BASE_URL'),
-  });
+export function createVisionAgent(languageModel: LanguageModelV3): Agent {
+  debugLog('Creating vision agent');
   return new Agent(components.agent, {
     name: 'vision-analyzer',
-    languageModel: openai.chatModel(visionModelId),
+    languageModel,
     instructions: `You are a vision AI that analyzes images and extracts information from them.
 
 Extract and transcribe visible text content accurately. Be specific - provide actual information visible, not just general descriptions.
diff --git a/services/platform/convex/agent_tools/files/image_tool.ts b/services/platform/convex/agent_tools/files/image_tool.ts
index a0b6b14c34..cc754dc154 100644
--- a/services/platform/convex/agent_tools/files/image_tool.ts
+++ b/services/platform/convex/agent_tools/files/image_tool.ts
@@ -15,7 +15,6 @@ import { createDebugLog } from '../../lib/debug_log';
 import { toId } from '../../lib/type_cast_helpers';
 import { analyzeImage } from './helpers/analyze_image';
 import { appendFilePart } from './helpers/append_file_part';
-import { getVisionModel } from './helpers/vision_agent';
 
 const debugLog = createDebugLog('DEBUG_AGENT_TOOLS', '[AgentTools]');
 
@@ -192,7 +191,7 @@ To also save the file to a folder in the documents hub, call document_write with
             operation: 'analyze',
             success: false,
             analysis: '',
-            model: getVisionModel(),
+            model: 'unknown',
             error: errorMessage,
           };
         }
diff --git a/services/platform/convex/agent_tools/files/text_tool.ts b/services/platform/convex/agent_tools/files/text_tool.ts
index e1baa7119e..429bfa5cf5 100644
--- a/services/platform/convex/agent_tools/files/text_tool.ts
+++ b/services/platform/convex/agent_tools/files/text_tool.ts
@@ -210,6 +210,9 @@ To also save the file to a folder in the documents hub, call document_write with
           filename: resolvedFilename,
           userInput: user_input,
           model,
+          // oxlint-disable-next-line typescript/no-non-null-assertion,typescript/no-unsafe-type-assertion -- ctx.agent is guaranteed non-null inside a tool execute callback
+          languageModel: ctx.agent!.options
+            .languageModel as import('@ai-sdk/provider').LanguageModelV3,
         });
 
         debugLog('tool:text parse success', {
diff --git a/services/platform/convex/agent_tools/human_input/mutations.ts b/services/platform/convex/agent_tools/human_input/mutations.ts
index abc60f418f..830060e762 100644
--- a/services/platform/convex/agent_tools/human_input/mutations.ts
+++ b/services/platform/convex/agent_tools/human_input/mutations.ts
@@ -10,10 +10,6 @@ import { FEEDBACK_KEY } from '../../../lib/shared/schemas/approvals';
 import { getString, isRecord } from '../../../lib/utils/type-guards';
 import { components, internal } from '../../_generated/api';
 import { mutation } from '../../_generated/server';
-import {
-  getDefaultAgentRuntimeConfig,
-  getDefaultModel,
-} from '../../lib/agent_runtime_config';
 import { getOrganizationMember } from '../../lib/rls';
 import { persistentStreaming } from '../../streaming/helpers';
 import { workflowManagers } from '../../workflow_engine/engine';
@@ -189,12 +185,11 @@ export const submitHumanInputResponse = mutation({
     // Use default model config for re-triggering after human input.
     // The agent config was already established when the thread was created;
     // the generation action will use the thread's existing context.
-    const { model, provider } = getDefaultAgentRuntimeConfig();
     const agentConfig = {
       name: 'chat-agent',
       instructions: '',
       convexToolNames: [],
-      model: getDefaultModel(),
+      model: 'default',
       enableVectorSearch: false,
       knowledgeMode: 'off' as const,
       webSearchMode: 'off' as const,
@@ -213,8 +208,8 @@ export const submitHumanInputResponse = mutation({
       {
         agentType: 'custom',
         agentConfig,
-        model: agentConfig.model ?? model,
-        provider,
+        model: agentConfig.model,
+        provider: 'file',
         debugTag: '[ChatAgent:HumanInput]',
         enableStreaming: true,
         hooks: { beforeGenerate },
diff --git a/services/platform/convex/agent_tools/location/mutations.ts b/services/platform/convex/agent_tools/location/mutations.ts
index 01f9203e31..8955e20bb1 100644
--- a/services/platform/convex/agent_tools/location/mutations.ts
+++ b/services/platform/convex/agent_tools/location/mutations.ts
@@ -8,10 +8,6 @@ import type { LocationRequestMetadata } from '../../../lib/shared/schemas/approv
 
 import { components, internal } from '../../_generated/api';
 import { mutation } from '../../_generated/server';
-import {
-  getDefaultAgentRuntimeConfig,
-  getDefaultModel,
-} from '../../lib/agent_runtime_config';
 import { getOrganizationMember } from '../../lib/rls';
 import { persistentStreaming } from '../../streaming/helpers';
 import { workflowManagers } from '../../workflow_engine/engine';
@@ -152,12 +148,11 @@ export const submitLocationResponse = mutation({
       threadId,
     });
 
-    const { model, provider } = getDefaultAgentRuntimeConfig();
     const agentConfig = {
       name: 'chat-agent',
       instructions: '',
       convexToolNames: [],
-      model: getDefaultModel(),
+      model: 'default',
       enableVectorSearch: false,
       knowledgeMode: 'off' as const,
       webSearchMode: 'off' as const,
@@ -176,8 +171,8 @@ export const submitLocationResponse = mutation({
       {
         agentType: 'custom',
         agentConfig,
-        model: agentConfig.model ?? model,
-        provider,
+        model: agentConfig.model,
+        provider: 'file',
         debugTag: '[ChatAgent:Location]',
         enableStreaming: true,
         hooks: { beforeGenerate },
diff --git a/services/platform/convex/agent_tools/workflows/internal_mutations.ts b/services/platform/convex/agent_tools/workflows/internal_mutations.ts
index eac351d987..78a3dc017c 100644
--- a/services/platform/convex/agent_tools/workflows/internal_mutations.ts
+++ b/services/platform/convex/agent_tools/workflows/internal_mutations.ts
@@ -12,7 +12,6 @@ import { jsonRecordValidator } from '../../../lib/shared/schemas/utils/json-valu
 import { components, internal } from '../../_generated/api';
 import { internalMutation } from '../../_generated/server';
 import { createApproval } from '../../approvals/helpers';
-import { getDefaultAgentRuntimeConfig } from '../../lib/agent_runtime_config';
 import { checkOrganizationRateLimit } from '../../lib/rate_limiter/helpers';
 import { persistentStreaming } from '../../streaming/helpers';
 import { stepConfigValidator } from '../../workflow_engine/types/nodes';
@@ -107,7 +106,6 @@ export const triggerWorkflowCompletionResponse = internalMutation({
       },
     );
 
-    const { model, provider } = getDefaultAgentRuntimeConfig();
     const streamId = await persistentStreaming.createStream(ctx);
 
     if (threadMeta) {
@@ -123,8 +121,8 @@ export const triggerWorkflowCompletionResponse = internalMutation({
       {
         agentType: 'custom',
         agentConfig,
-        model: agentConfig.model ?? model,
-        provider,
+        model: agentConfig.model ?? 'default',
+        provider: 'file',
         debugTag: `[${agentSlug}:WorkflowComplete]`,
         enableStreaming: true,
         threadId,
diff --git a/services/platform/convex/agents/config.ts b/services/platform/convex/agents/config.ts
index d770b9f946..27f546de0e 100644
--- a/services/platform/convex/agents/config.ts
+++ b/services/platform/convex/agents/config.ts
@@ -3,11 +3,6 @@
  *
  * This is the thin mapping layer between the agent JSON file format
  * and the existing agent pipeline.
- *
- * Model preset resolution:
- * - 'fast' → OPENAI_FAST_MODEL
- * - 'standard' → OPENAI_MODEL (default)
- * - 'advanced' → OPENAI_CODING_MODEL
  */
 
 import type { ToolName } from '../agent_tools/tool_names';
@@ -15,25 +10,6 @@ import type { SerializableAgentConfig } from '../lib/agent_chat/types';
 import type { AgentJsonConfig } from './file_utils';
 import type { KnowledgeFile } from './schema';
 
-import {
-  getCodingModelOrThrow,
-  getDefaultModel,
-  getFastModel,
-} from '../lib/agent_runtime_config';
-
-function resolveModel(config: AgentJsonConfig): string {
-  if (config.modelId) return config.modelId;
-
-  switch (config.modelPreset) {
-    case 'fast':
-      return getFastModel();
-    case 'advanced':
-      return getCodingModelOrThrow();
-    default:
-      return getDefaultModel();
-  }
-}
-
 export function toSerializableConfig(
   agentName: string,
   config: AgentJsonConfig,
@@ -51,7 +27,11 @@ export function toSerializableConfig(
     convexToolNames: config.toolNames as ToolName[],
     integrationBindings: config.integrationBindings,
     workflowBindings: config.workflows,
-    model: resolveModel(config),
+    model:
+      config.supportedModels[0] ??
+      (() => {
+        throw new Error('supportedModels must not be empty');
+      })(),
     maxSteps: config.maxSteps,
     enableVectorSearch: false,
     knowledgeMode,
diff --git a/services/platform/convex/agents/file_actions.ts b/services/platform/convex/agents/file_actions.ts
index 94dfef2627..f473024924 100644
--- a/services/platform/convex/agents/file_actions.ts
+++ b/services/platform/convex/agents/file_actions.ts
@@ -102,7 +102,7 @@ export const listAgents = action({
             displayName: result.config.displayName,
             description: result.config.description,
             visibleInChat: result.config.visibleInChat,
-            modelPreset: result.config.modelPreset,
+            supportedModels: result.config.supportedModels,
             toolNames: result.config.toolNames,
             roleRestriction: result.config.roleRestriction,
             conversationStarters: result.config.conversationStarters,
diff --git a/services/platform/convex/agents/file_utils.ts b/services/platform/convex/agents/file_utils.ts
index 1e5019ed91..051d6745e1 100644
--- a/services/platform/convex/agents/file_utils.ts
+++ b/services/platform/convex/agents/file_utils.ts
@@ -33,8 +33,7 @@ export interface AgentJsonConfig {
   integrationBindings?: string[];
   delegates?: string[];
   workflows?: string[];
-  modelPreset?: 'fast' | 'standard' | 'advanced';
-  modelId?: string;
+  supportedModels: string[];
   knowledgeMode?: 'off' | 'tool' | 'context' | 'both';
   webSearchMode?: 'off' | 'tool' | 'context' | 'both';
   includeOrgKnowledge?: boolean;
diff --git a/services/platform/convex/agents/queries.ts b/services/platform/convex/agents/queries.ts
index 9b69d09c6d..a913c731ad 100644
--- a/services/platform/convex/agents/queries.ts
+++ b/services/platform/convex/agents/queries.ts
@@ -7,7 +7,6 @@
 
 import { v } from 'convex/values';
 
-import { parseModelList } from '../../lib/shared/utils/model-list';
 import { query } from '../_generated/server';
 import { TOOL_NAMES } from '../agent_tools/tool_names';
 import { authComponent } from '../auth';
@@ -105,18 +104,3 @@ export const getAvailableIntegrations = query({
     return integrations;
   },
 });
-
-export const getModelPresets = query({
-  args: {},
-  handler: async (): Promise<{
-    fast: string[];
-    standard: string[];
-    advanced: string[];
-  }> => {
-    return {
-      fast: parseModelList(process.env.OPENAI_FAST_MODEL),
-      standard: parseModelList(process.env.OPENAI_MODEL),
-      advanced: parseModelList(process.env.OPENAI_CODING_MODEL),
-    };
-  },
-});
diff --git a/services/platform/convex/agents/start_chat.ts b/services/platform/convex/agents/start_chat.ts
index 8a32f14105..57369a437a 100644
--- a/services/platform/convex/agents/start_chat.ts
+++ b/services/platform/convex/agents/start_chat.ts
@@ -11,7 +11,6 @@ import { v } from 'convex/values';
 import { components } from '../_generated/api';
 import { internalMutation } from '../_generated/server';
 import { startAgentChat } from '../lib/agent_chat';
-import { getDefaultAgentRuntimeConfig } from '../lib/agent_runtime_config';
 import { getOrganizationMember } from '../lib/rls';
 
 export const startChat = internalMutation({
@@ -61,8 +60,6 @@ export const startChat = internalMutation({
       throw new Error('Thread not found');
     }
 
-    const { model, provider } = getDefaultAgentRuntimeConfig();
-
     return startAgentChat({
       ctx,
       agentType: 'custom',
@@ -74,8 +71,8 @@ export const startChat = internalMutation({
       additionalContext: args.additionalContext,
       userContext: args.userContext,
       agentConfig: args.agentConfig,
-      model: args.agentConfig.model ?? model,
-      provider,
+      model: args.agentConfig.model ?? 'default',
+      provider: 'file',
       agentSlug: args.agentSlug,
       debugTag: `[${args.agentSlug}]`,
       enableStreaming: true,
diff --git a/services/platform/convex/agents/test_chat.test.ts b/services/platform/convex/agents/test_chat.test.ts
index cf51a56743..f66376d55c 100644
--- a/services/platform/convex/agents/test_chat.test.ts
+++ b/services/platform/convex/agents/test_chat.test.ts
@@ -1,15 +1,9 @@
-import { describe, it, expect, beforeAll } from 'vitest';
+import { describe, it, expect } from 'vitest';
 
 import type { AgentJsonConfig } from './file_utils';
 
 import { toSerializableConfig } from './config';
 
-beforeAll(() => {
-  process.env.OPENAI_MODEL = 'gpt-4o';
-  process.env.OPENAI_FAST_MODEL = 'gpt-4o-mini';
-  process.env.OPENAI_CODING_MODEL = 'o3';
-});
-
 function createMockConfig(
   overrides: Partial<AgentJsonConfig> = {},
 ): AgentJsonConfig {
@@ -19,7 +13,7 @@ function createMockConfig(
     systemInstructions: 'You are a helpful test agent.',
     toolNames: ['web', 'rag_search'],
     integrationBindings: ['integration_1'],
-    modelPreset: 'standard',
+    supportedModels: ['moonshotai/kimi-k2.5', 'deepseek/deepseek-v3.2'],
     includeOrgKnowledge: true,
     maxSteps: 10,
     timeoutMs: 60000,
@@ -37,36 +31,18 @@ describe('toSerializableConfig', () => {
     expect(result.instructions).toBe('You are a helpful test agent.');
     expect(result.convexToolNames).toEqual(['web', 'rag_search']);
     expect(result.integrationBindings).toEqual(['integration_1']);
-    expect(result.model).toBe('gpt-4o');
+    expect(result.model).toBe('moonshotai/kimi-k2.5');
     expect(result.maxSteps).toBe(10);
     expect(result.timeoutMs).toBe(60000);
     expect(result.outputReserve).toBe(2048);
   });
 
-  it('should resolve model presets correctly', () => {
-    expect(
-      toSerializableConfig('a', createMockConfig({ modelPreset: 'fast' }))
-        .model,
-    ).toBe('gpt-4o-mini');
-
-    expect(
-      toSerializableConfig('a', createMockConfig({ modelPreset: 'advanced' }))
-        .model,
-    ).toBe('o3');
-
-    expect(
-      toSerializableConfig('a', createMockConfig({ modelPreset: 'standard' }))
-        .model,
-    ).toBe('gpt-4o');
-  });
-
-  it('should prefer modelId over modelPreset', () => {
+  it('should use first supportedModels entry as model', () => {
     const config = createMockConfig({
-      modelPreset: 'fast',
-      modelId: 'custom-model',
+      supportedModels: ['anthropic/claude-opus-4.6', 'openai/gpt-5.2'],
     });
     const result = toSerializableConfig('a', config);
-    expect(result.model).toBe('custom-model');
+    expect(result.model).toBe('anthropic/claude-opus-4.6');
   });
 
   it('should merge knowledge files from binding', () => {
diff --git a/services/platform/convex/agents/translate_fields.ts b/services/platform/convex/agents/translate_fields.ts
index f50b1f208a..4a7e9a0ce6 100644
--- a/services/platform/convex/agents/translate_fields.ts
+++ b/services/platform/convex/agents/translate_fields.ts
@@ -1,13 +1,12 @@
 'use node';
 
+import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
 import { Agent } from '@convex-dev/agent';
 import { z } from 'zod';
 
 import type { ActionCtx } from '../_generated/server';
 
-import { components } from '../_generated/api';
-import { getFastModel } from '../lib/agent_runtime_config';
-import { openai } from '../lib/openai_provider';
+import { components, internal } from '../_generated/api';
 
 const MAX_RETRIES = 3;
 
@@ -24,12 +23,13 @@ export type TranslateInput = Record<string, string | string[]>;
  */
 export type TranslateOutput = Record<string, string | string[]>;
 
-function createTranslationAgent(targetLocale: string) {
-  const model = getFastModel();
-
+function createTranslationAgent(
+  targetLocale: string,
+  languageModel: import('@ai-sdk/provider').LanguageModelV3,
+) {
   return new Agent(components.agent, {
     name: 'field-translator',
-    languageModel: openai.chatModel(model),
+    languageModel,
     instructions: `You are a translation assistant. Translate the given texts to the locale "${targetLocale}".
 
 Rules:
@@ -103,7 +103,27 @@ export async function translateFields(
     translated: z.array(z.string()).length(flat.length),
   });
 
-  const agent = createTranslationAgent(args.targetLocale);
+  // Resolve chat model from provider files
+  // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- resolveModelByTag returns v.any() but shape is guaranteed by provider file_actions contract
+  const modelData = (await ctx.runAction(
+    internal.providers.file_actions.resolveModelByTag,
+    { tag: 'chat' },
+  )) as {
+    providerName: string;
+    baseUrl: string;
+    apiKey: string;
+    modelId: string;
+    supportsStructuredOutputs: boolean;
+  };
+  const providerInstance = createOpenAICompatible({
+    name: modelData.providerName,
+    baseURL: modelData.baseUrl,
+    apiKey: modelData.apiKey,
+    supportsStructuredOutputs: modelData.supportsStructuredOutputs,
+  });
+  const languageModel = providerInstance.chatModel(modelData.modelId);
+
+  const agent = createTranslationAgent(args.targetLocale, languageModel);
   const userId = `translate-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
   const prompt = JSON.stringify(flat);
 
diff --git a/services/platform/convex/agents/webhooks/internal_mutations.ts b/services/platform/convex/agents/webhooks/internal_mutations.ts
index f0dddb1a48..023546a49d 100644
--- a/services/platform/convex/agents/webhooks/internal_mutations.ts
+++ b/services/platform/convex/agents/webhooks/internal_mutations.ts
@@ -2,7 +2,6 @@ import { v } from 'convex/values';
 
 import { internalMutation } from '../../_generated/server';
 import { startAgentChat } from '../../lib/agent_chat';
-import { getDefaultAgentRuntimeConfig } from '../../lib/agent_runtime_config';
 import { createChatThread } from '../../threads/create_chat_thread';
 
 export const updateWebhookLastTriggered = internalMutation({
@@ -50,8 +49,6 @@ export const startWebhookChat = internalMutation({
       args.threadId ??
       (await createChatThread(ctx, userId, undefined, 'general'));
 
-    const { model, provider } = getDefaultAgentRuntimeConfig();
-
     const result = await startAgentChat({
       ctx,
       agentType: 'custom',
@@ -60,8 +57,8 @@ export const startWebhookChat = internalMutation({
       message: args.message,
       attachments: args.attachments,
       agentConfig: args.agentConfig,
-      model: args.agentConfig.model ?? model,
-      provider,
+      model: args.agentConfig.model ?? 'default',
+      provider: 'file',
       agentSlug: args.agentSlug,
       debugTag: `[${args.agentSlug}:webhook]`,
       enableStreaming: args.enableStreaming ?? true,
diff --git a/services/platform/convex/conversations/actions.ts b/services/platform/convex/conversations/actions.ts
index 2ae2a26559..4d17378e2d 100644
--- a/services/platform/convex/conversations/actions.ts
+++ b/services/platform/convex/conversations/actions.ts
@@ -1,5 +1,7 @@
+import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
 import { v } from 'convex/values';
 
+import { internal } from '../_generated/api';
 import { action } from '../_generated/server';
 import { authComponent } from '../auth';
 import { improveMessage as improveMessageHandler } from './improve_message';
@@ -22,6 +24,29 @@ export const improveMessage = action({
       throw new Error('Unauthenticated');
     }
 
-    return improveMessageHandler(ctx, args);
+    // Resolve fast/chat model from provider files
+    // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- resolveModelByTag returns v.any() but shape is guaranteed by provider file_actions contract
+    const modelData = (await ctx.runAction(
+      internal.providers.file_actions.resolveModelByTag,
+      { tag: 'chat' },
+    )) as {
+      providerName: string;
+      baseUrl: string;
+      apiKey: string;
+      modelId: string;
+      supportsStructuredOutputs: boolean;
+    };
+    const provider = createOpenAICompatible({
+      name: modelData.providerName,
+      baseURL: modelData.baseUrl,
+      apiKey: modelData.apiKey,
+      supportsStructuredOutputs: modelData.supportsStructuredOutputs,
+    });
+    const languageModel = provider.chatModel(modelData.modelId);
+
+    return improveMessageHandler(ctx, {
+      ...args,
+      languageModel,
+    });
   },
 });
diff --git a/services/platform/convex/conversations/improve_message.ts b/services/platform/convex/conversations/improve_message.ts
index 3e742e142e..ea3470030c 100644
--- a/services/platform/convex/conversations/improve_message.ts
+++ b/services/platform/convex/conversations/improve_message.ts
@@ -1,17 +1,18 @@
+import type { LanguageModelV3 } from '@ai-sdk/provider';
+
 import { Agent } from '@convex-dev/agent';
 
 import type { ActionCtx } from '../_generated/server';
 
 import { components } from '../_generated/api';
-import { getFastModel } from '../lib/agent_runtime_config';
-import { openai } from '../lib/openai_provider';
-
-function createImproveMessageAgent(instruction?: string) {
-  const model = getFastModel();
 
+function createImproveMessageAgent(
+  languageModel: LanguageModelV3,
+  instruction?: string,
+) {
   return new Agent(components.agent, {
     name: 'message-improver',
-    languageModel: openai.chatModel(model),
+    languageModel,
     instructions: `You are a helpful assistant that improves written messages for clarity, professionalism, and tone.
 Your task is to improve the given message while keeping its core meaning intact.
 ${instruction ? `Additional instruction: ${instruction}` : ''}
@@ -27,10 +28,17 @@ Guidelines:
 
 export async function improveMessage(
   ctx: ActionCtx,
-  args: { originalMessage: string; instruction?: string },
+  args: {
+    originalMessage: string;
+    instruction?: string;
+    languageModel: LanguageModelV3;
+  },
 ): Promise<{ improvedMessage: string; error?: string }> {
   try {
-    const agent = createImproveMessageAgent(args.instruction);
+    const agent = createImproveMessageAgent(
+      args.languageModel,
+      args.instruction,
+    );
     const userId = `improve-msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
 
     const result = await agent.generateText(
diff --git a/services/platform/convex/lib/agent_chat/internal_actions.ts b/services/platform/convex/lib/agent_chat/internal_actions.ts
index bd4db0e354..1ad5699cd3 100644
--- a/services/platform/convex/lib/agent_chat/internal_actions.ts
+++ b/services/platform/convex/lib/agent_chat/internal_actions.ts
@@ -2,6 +2,7 @@
 
 import type { FunctionHandle } from 'convex/server';
 
+import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
 import {
   Agent,
   listMessages,
@@ -277,26 +278,59 @@ export const runAgentGeneration = internalAction({
       ? agentConfig.instructions + delegationInstructionsAppend
       : agentConfig.instructions;
 
+    // Resolve model from provider files
+    const modelId = model === 'default' ? undefined : model;
+    // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- resolveModelByTag/resolveModelData returns v.any() but shape is guaranteed by provider file_actions contract
+    const modelData = modelId
+      ? ((await ctx.runAction(
+          internal.providers.file_actions.resolveModelData,
+          { modelId },
+        )) as {
+          providerName: string;
+          baseUrl: string;
+          apiKey: string;
+          modelId: string;
+          supportsStructuredOutputs: boolean;
+        })
+      : ((await ctx.runAction(
+          internal.providers.file_actions.resolveModelByTag,
+          { tag: 'chat' },
+        )) as {
+          providerName: string;
+          baseUrl: string;
+          apiKey: string;
+          modelId: string;
+          supportsStructuredOutputs: boolean;
+        });
+    const providerInstance = createOpenAICompatible({
+      name: modelData.providerName,
+      baseURL: modelData.baseUrl,
+      apiKey: modelData.apiKey,
+      supportsStructuredOutputs: modelData.supportsStructuredOutputs,
+    });
+    const languageModel = providerInstance.chatModel(modelData.modelId);
+
     // Create agent factory function from serializable config
     const createAgent = (options?: Record<string, unknown>) => {
+      // If a model override is provided via options, resolve it inline
+      // (this is a sync context, so we use the already-resolved languageModel)
+      const effectiveLanguageModel = languageModel;
+
       const config = createAgentConfig({
         name: agentConfig.name,
         instructions: finalInstructions,
+        languageModel: effectiveLanguageModel,
         convexToolNames: agentConfig.convexToolNames
           ? agentConfig.convexToolNames.filter((n): n is ToolName =>
               (TOOL_NAMES as readonly string[]).includes(n),
             )
           : undefined,
         extraTools: allExtraTools,
-        model:
-          (typeof options?.model === 'string' ? options.model : undefined) ??
-          agentConfig.model,
         maxSteps:
           (typeof options?.maxSteps === 'number'
             ? options.maxSteps
             : undefined) ?? agentConfig.maxSteps,
         outputFormat: agentConfig.outputFormat,
-        enableVectorSearch: agentConfig.enableVectorSearch,
       });
       return new Agent(components.agent, config);
     };
diff --git a/services/platform/convex/lib/attachments/process_attachments.ts b/services/platform/convex/lib/attachments/process_attachments.ts
index 92674e21cb..d867b1675a 100644
--- a/services/platform/convex/lib/attachments/process_attachments.ts
+++ b/services/platform/convex/lib/attachments/process_attachments.ts
@@ -5,11 +5,16 @@
  * including document parsing and image metadata extraction.
  */
 
+import type { LanguageModelV3 } from '@ai-sdk/provider';
+
+import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
+
 import type { Id } from '../../_generated/dataModel';
 import type { ActionCtx } from '../../_generated/server';
 import type { FileAttachment, MessageContentPart } from './types';
 
 import { isImage, isTextFile } from '../../../lib/shared/file-types';
+import { internal } from '../../_generated/api';
 import { analyzeImageCached } from '../../agent_tools/files/helpers/analyze_image';
 import { analyzeTextContent } from '../../agent_tools/files/helpers/analyze_text';
 import { parseFile } from '../../agent_tools/files/helpers/parse_file';
@@ -62,6 +67,7 @@ export interface ProcessAttachmentsConfig {
   debugLog?: (message: string, data?: Record<string, unknown>) => void;
   toolName?: string;
   model?: string;
+  languageModel?: LanguageModelV3;
 }
 
 const DEFAULT_MAX_DOCUMENT_LENGTH = 50000;
@@ -192,6 +198,29 @@ export async function processAttachments(
     (r): r is { fileName: string; analysis: string } => r !== null,
   );
 
+  // Resolve language model for text analysis if not provided
+  let resolvedLanguageModelV3 = config.languageModel;
+  if (!resolvedLanguageModelV3 && textFileAttachments.length > 0) {
+    // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- resolveModelByTag returns v.any() but shape is guaranteed by provider file_actions contract
+    const modelData = (await ctx.runAction(
+      internal.providers.file_actions.resolveModelByTag,
+      { tag: 'chat' },
+    )) as {
+      providerName: string;
+      baseUrl: string;
+      apiKey: string;
+      modelId: string;
+      supportsStructuredOutputs: boolean;
+    };
+    const providerInstance = createOpenAICompatible({
+      name: modelData.providerName,
+      baseURL: modelData.baseUrl,
+      apiKey: modelData.apiKey,
+      supportsStructuredOutputs: modelData.supportsStructuredOutputs,
+    });
+    resolvedLanguageModelV3 = providerInstance.chatModel(modelData.modelId);
+  }
+
   // Analyze text files with LLM (in parallel)
   const textAnalysisResults = await Promise.all(
     textFileAttachments.map(async (attachment) => {
@@ -201,6 +230,9 @@ export async function processAttachments(
           filename: attachment.fileName,
           userInput: userText || 'Analyze this file',
           model: config.model,
+          // resolvedLanguageModelV3 is guaranteed set: either from config or resolved above
+          // oxlint-disable-next-line typescript/no-non-null-assertion -- guard above ensures non-null
+          languageModel: resolvedLanguageModelV3!,
         });
 
         if (result.success) {
diff --git a/services/platform/convex/lib/create_agent_config.ts b/services/platform/convex/lib/create_agent_config.ts
index 6d57a0d205..7c69583b08 100644
--- a/services/platform/convex/lib/create_agent_config.ts
+++ b/services/platform/convex/lib/create_agent_config.ts
@@ -1,12 +1,11 @@
+import type { LanguageModelV3 } from '@ai-sdk/provider';
+
 import { Agent } from '@convex-dev/agent';
 
 import type { ToolName } from '../agent_tools/tool_registry';
 
 import { loadConvexToolsAsObject } from '../agent_tools/load_convex_tools_as_object';
-import { getDefaultModel } from './agent_runtime_config';
 import { createDebugLog } from './debug_log';
-import { getEnvOrThrow } from './get_or_throw';
-import { openai } from './openai_provider';
 
 const debugLog = createDebugLog('DEBUG_CHAT_AGENT', '[AgentConfig]');
 
@@ -21,7 +20,10 @@ const debugLog = createDebugLog('DEBUG_CHAT_AGENT', '[AgentConfig]');
  */
 export function createAgentConfig(opts: {
   name: string;
-  model?: string;
+  /** Pre-resolved language model instance from the provider */
+  languageModel: LanguageModelV3;
+  /** Pre-resolved text embedding model for vector search (pass when enableVectorSearch is true) */
+  textEmbeddingModel?: unknown;
   /** Temperature override. If not provided, auto-determined by outputFormat: json→0.2, text→0.5 */
   temperature?: number;
   maxTokens?: number;
@@ -32,8 +34,6 @@ export function createAgentConfig(opts: {
   /** Additional tools to merge (e.g., dynamic json_output tool) */
   extraTools?: Record<string, unknown>;
   maxSteps?: number;
-  /** Enable vector search for finding semantically relevant older messages (defaults to false) */
-  enableVectorSearch?: boolean;
 }): ConstructorParameters<typeof Agent>[1] {
   // Build Convex tools as an object when names are provided
   const convexToolsObject = opts.convexToolNames?.length
@@ -140,31 +140,11 @@ Example: User asks for "John's email" and you find 3 Johns:
     estimatedInstructionTokens,
   });
 
-  const getModel = (): string => {
-    if (opts.model) {
-      return opts.model;
-    }
-    return getDefaultModel();
-  };
-
-  const model = getModel();
-
   // Call settings are intentionally empty
   // temperature and frequencyPenalty are not supported by reasoning models (e.g., DeepSeek V3.2)
   // and cause empty responses when set. Let the model use its defaults.
   const callSettings: Record<string, number> = {};
 
-  // Build text embedding model for vector search if enabled
-  // Requires OPENAI_EMBEDDING_MODEL env var to be set
-  let embeddingModel: string | undefined;
-  const enableVectorSearch = opts.enableVectorSearch ?? false;
-  if (enableVectorSearch) {
-    embeddingModel = getEnvOrThrow(
-      'OPENAI_EMBEDDING_MODEL',
-      'Embedding model - required when enableVectorSearch is true',
-    );
-  }
-
   // Default maxSteps to 40 when tools are configured but maxSteps is not set.
   // Without maxSteps, AI SDK defaults to stepCountIs(1), which prevents tool call loops
   // and can cause models to "simulate" tool calls as XML text output.
@@ -175,7 +155,7 @@ Example: User asks for "John's email" and you find 3 Johns:
   return {
     name: opts.name,
     instructions: finalInstructions,
-    languageModel: openai.chatModel(model),
+    languageModel: opts.languageModel,
     callSettings,
     ...(typeof opts.maxTokens === 'number'
       ? { providerOptions: { openai: { maxOutputTokens: opts.maxTokens } } }
@@ -184,8 +164,8 @@ Example: User asks for "John's email" and you find 3 Johns:
     ...(typeof effectiveMaxSteps === 'number'
       ? { maxSteps: effectiveMaxSteps }
       : {}),
-    ...(embeddingModel
-      ? { embeddingModel: openai.textEmbeddingModel(embeddingModel) }
+    ...(opts.textEmbeddingModel
+      ? { embeddingModel: opts.textEmbeddingModel }
       : {}),
   } as ConstructorParameters<typeof Agent>[1];
 }
diff --git a/services/platform/convex/lib/sops.ts b/services/platform/convex/lib/sops.ts
new file mode 100644
index 0000000000..ab2b33e18a
--- /dev/null
+++ b/services/platform/convex/lib/sops.ts
@@ -0,0 +1,57 @@
+'use node';
+
+/**
+ * SOPS decryption utility.
+ *
+ * Decrypts SOPS-encrypted JSON files using the `sops` CLI binary.
+ * Results are cached in memory and invalidated explicitly via `clearCache()`.
+ */
+
+import { execSync } from 'node:child_process';
+import { stat } from 'node:fs/promises';
+
+interface CacheEntry {
+  data: Record<string, unknown>;
+  mtimeMs: number;
+}
+
+const cache = new Map<string, CacheEntry>();
+
+export async function decryptSecretsFile(
+  filePath: string,
+): Promise<Record<string, unknown>> {
+  const fileStat = await stat(filePath);
+  const cached = cache.get(filePath);
+  if (cached && cached.mtimeMs === fileStat.mtimeMs) {
+    return cached.data;
+  }
+
+  let stdout: string;
+  try {
+    stdout = execSync(`sops -d --output-type json "${filePath}"`, {
+      encoding: 'utf-8',
+      timeout: 10_000,
+      stdio: ['pipe', 'pipe', 'pipe'],
+    });
+  } catch (err) {
+    const message = err instanceof Error ? err.message : String(err);
+    throw new Error(
+      `Failed to decrypt secrets file ${filePath}: ${message}. ` +
+        'Ensure sops is installed and SOPS_AGE_KEY or SOPS_AGE_KEY_FILE is set.',
+      { cause: err },
+    );
+  }
+
+  // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- JSON.parse returns unknown; we validate via providerSecretsSchema downstream
+  const data = JSON.parse(stdout) as Record<string, unknown>;
+  cache.set(filePath, { data, mtimeMs: fileStat.mtimeMs });
+  return data;
+}
+
+export function clearSopsCache(filePath?: string): void {
+  if (filePath) {
+    cache.delete(filePath);
+  } else {
+    cache.clear();
+  }
+}
diff --git a/services/platform/convex/lib/summarization/auto_summarize.ts b/services/platform/convex/lib/summarization/auto_summarize.ts
index 4e6c49392e..414cd91373 100644
--- a/services/platform/convex/lib/summarization/auto_summarize.ts
+++ b/services/platform/convex/lib/summarization/auto_summarize.ts
@@ -14,6 +14,8 @@
  * This ensures the model always has a coherent view of the conversation.
  */
 
+import type { LanguageModelV3 } from '@ai-sdk/provider';
+
 import { listMessages, type MessageDoc } from '@convex-dev/agent';
 
 import type { ActionCtx } from '../../_generated/server';
@@ -69,6 +71,7 @@ interface ThreadSummaryData {
 
 export interface AutoSummarizeIfNeededArgs {
   threadId: string;
+  languageModel: LanguageModelV3;
 }
 
 export interface AutoSummarizeIfNeededResult {
@@ -247,10 +250,17 @@ export async function autoSummarizeIfNeededModel(
       ctx,
       existingSummary,
       newMessagesForSummary,
+      undefined,
+      args.languageModel,
     );
   } else {
     // First summary: summarize all new messages
-    newSummary = await summarizeMessages(ctx, newMessagesForSummary);
+    newSummary = await summarizeMessages(
+      ctx,
+      newMessagesForSummary,
+      undefined,
+      args.languageModel,
+    );
   }
 
   // Update thread metadata
diff --git a/services/platform/convex/lib/summarization/internal_actions.ts b/services/platform/convex/lib/summarization/internal_actions.ts
index 04c99ae304..fe3cb87723 100644
--- a/services/platform/convex/lib/summarization/internal_actions.ts
+++ b/services/platform/convex/lib/summarization/internal_actions.ts
@@ -1,5 +1,7 @@
+import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
 import { v } from 'convex/values';
 
+import { internal } from '../../_generated/api';
 import { internalAction } from '../../_generated/server';
 import { autoSummarizeIfNeededModel } from './auto_summarize';
 
@@ -14,6 +16,29 @@ export const autoSummarizeIfNeeded = internalAction({
     totalMessagesSummarized: v.number(),
   }),
   handler: async (ctx, args) => {
-    return await autoSummarizeIfNeededModel(ctx, args);
+    // Resolve chat model from provider files
+    // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- resolveModelByTag returns v.any() but shape is guaranteed by provider file_actions contract
+    const modelData = (await ctx.runAction(
+      internal.providers.file_actions.resolveModelByTag,
+      { tag: 'chat' },
+    )) as {
+      providerName: string;
+      baseUrl: string;
+      apiKey: string;
+      modelId: string;
+      supportsStructuredOutputs: boolean;
+    };
+    const provider = createOpenAICompatible({
+      name: modelData.providerName,
+      baseURL: modelData.baseUrl,
+      apiKey: modelData.apiKey,
+      supportsStructuredOutputs: modelData.supportsStructuredOutputs,
+    });
+    const languageModel = provider.chatModel(modelData.modelId);
+
+    return await autoSummarizeIfNeededModel(ctx, {
+      ...args,
+      languageModel,
+    });
   },
 });
diff --git a/services/platform/convex/lib/summarize_context.ts b/services/platform/convex/lib/summarize_context.ts
index 1006d7709f..e22b770c86 100644
--- a/services/platform/convex/lib/summarize_context.ts
+++ b/services/platform/convex/lib/summarize_context.ts
@@ -8,14 +8,14 @@
  * - Example: 100K tokens → multiple iterations until all processed
  */
 
+import type { LanguageModelV3 } from '@ai-sdk/provider';
+
 import { Agent } from '@convex-dev/agent';
 
 import type { ActionCtx } from '../_generated/server';
 
 import { components } from '../_generated/api';
-import { getDefaultModel } from './agent_runtime_config';
 import { createDebugLog } from './debug_log';
-import { openai } from './openai_provider';
 
 const debugLog = createDebugLog('DEBUG_CONTEXT_SUMMARY', '[ContextSummary]');
 
@@ -73,12 +73,13 @@ Output ONLY the updated summary, not commentary about changes.`;
 /**
  * Create a lightweight summarizer agent (no tools, just for summarization)
  */
-function createSummarizerAgent(incremental: boolean = false): Agent {
-  const envModel = getDefaultModel();
-
+function createSummarizerAgent(
+  languageModel: LanguageModelV3,
+  incremental: boolean = false,
+): Agent {
   return new Agent(components.agent, {
     name: 'summarizer',
-    languageModel: openai.chatModel(envModel),
+    languageModel,
     instructions: incremental
       ? INCREMENTAL_SUMMARIZATION_INSTRUCTIONS
       : SUMMARIZATION_INSTRUCTIONS,
@@ -186,7 +187,8 @@ function splitIntoTokenChunks(
 export async function summarizeMessages(
   ctx: ActionCtx,
   messages: MessageForSummary[],
-  currentPrompt?: string,
+  currentPrompt: string | undefined,
+  languageModel: LanguageModelV3,
 ): Promise<string> {
   if (messages.length === 0) {
     debugLog('summarizeMessages No messages to summarize');
@@ -198,7 +200,12 @@ export async function summarizeMessages(
 
   // If only one chunk, summarize directly
   if (chunks.length === 1) {
-    return await summarizeSingleChunk(ctx, chunks[0], currentPrompt);
+    return await summarizeSingleChunk(
+      ctx,
+      chunks[0],
+      currentPrompt,
+      languageModel,
+    );
   }
 
   // CHUNKED SUMMARIZATION: process multiple chunks
@@ -223,7 +230,12 @@ export async function summarizeMessages(
 
     if (chunkIndex === 0) {
       // First chunk: create initial summary
-      rollingSummary = await summarizeSingleChunk(ctx, chunk, currentPrompt);
+      rollingSummary = await summarizeSingleChunk(
+        ctx,
+        chunk,
+        currentPrompt,
+        languageModel,
+      );
     } else {
       // Subsequent chunks: update existing summary with new messages
       rollingSummary = await updateSummary(
@@ -231,6 +243,7 @@ export async function summarizeMessages(
         rollingSummary,
         chunk,
         currentPrompt,
+        languageModel,
       );
     }
 
@@ -253,14 +266,15 @@ export async function summarizeMessages(
 async function summarizeSingleChunk(
   ctx: ActionCtx,
   messages: MessageForSummary[],
-  currentPrompt?: string,
+  currentPrompt: string | undefined,
+  languageModel: LanguageModelV3,
 ): Promise<string> {
   const formattedMessages = formatMessagesForSummary(messages);
   debugLog(
     `summarizeSingleChunk Formatted ${messages.length} messages, total chars: ${formattedMessages.length}`,
   );
 
-  const summarizer = createSummarizerAgent(false);
+  const summarizer = createSummarizerAgent(languageModel, false);
 
   let prompt = '';
   if (currentPrompt) {
@@ -306,14 +320,15 @@ export async function updateSummary(
   ctx: ActionCtx,
   existingSummary: string,
   newMessages: MessageForSummary[],
-  currentPrompt?: string,
+  currentPrompt: string | undefined,
+  languageModel: LanguageModelV3,
 ): Promise<string> {
   if (newMessages.length === 0) {
     return existingSummary;
   }
 
   const formattedNewMessages = formatMessagesForSummary(newMessages);
-  const summarizer = createSummarizerAgent(true);
+  const summarizer = createSummarizerAgent(languageModel, true);
 
   let prompt = `## Existing Summary
 
diff --git a/services/platform/convex/providers/file_actions.ts b/services/platform/convex/providers/file_actions.ts
new file mode 100644
index 0000000000..bda44ac50f
--- /dev/null
+++ b/services/platform/convex/providers/file_actions.ts
@@ -0,0 +1,442 @@
+'use node';
+
+/**
+ * Provider file I/O actions.
+ *
+ * CRUD actions for provider JSON files + lightweight model resolution actions
+ * that return pure serializable data (no provider instances).
+ *
+ * Non-node callers use ctx.runAction() to get provider data, then create
+ * provider instances locally with @ai-sdk/openai-compatible.
+ */
+
+import { v } from 'convex/values';
+import { readdir, unlink } from 'node:fs/promises';
+import path from 'node:path';
+
+import type { ProviderSecrets } from '../../lib/shared/schemas/providers';
+import type { ProviderJson, ProviderReadResult } from './file_utils';
+
+import { providerJsonSchema } from '../../lib/shared/schemas/providers';
+import { action, internalAction } from '../_generated/server';
+import { authComponent } from '../auth';
+import { atomicWrite, readJsonFile, sha256 } from '../lib/file_io';
+import { decryptSecretsFile } from '../lib/sops';
+import {
+  MAX_FILE_SIZE_BYTES,
+  parseProviderJson,
+  parseProviderSecrets,
+  providerNameFromFileName,
+  resolveProviderFilePath,
+  resolveProviderSecretsPath,
+  resolveProvidersDir,
+  serializeProviderJson,
+  validateProviderName,
+} from './file_utils';
+
+// ---------------------------------------------------------------------------
+// Internal helpers
+// ---------------------------------------------------------------------------
+
+async function readProviderFile(
+  orgSlug: string,
+  providerName: string,
+): Promise<ProviderReadResult> {
+  const filePath = resolveProviderFilePath(orgSlug, providerName);
+  const result = await readJsonFile<ProviderJson>(
+    filePath,
+    MAX_FILE_SIZE_BYTES,
+    parseProviderJson,
+  );
+  if (result.ok) return { ok: true, config: result.data, hash: result.hash };
+  return result;
+}
+
+interface ProviderWithSecrets {
+  name: string;
+  config: ProviderJson;
+  secrets: ProviderSecrets;
+}
+
+async function loadAllProviders(
+  orgSlug: string,
+): Promise<ProviderWithSecrets[]> {
+  const dir = resolveProvidersDir(orgSlug);
+  let entries: string[];
+  try {
+    entries = await readdir(dir);
+  } catch {
+    throw new Error(
+      `Provider directory not found: ${dir}. ` +
+        'Create at least one provider JSON file in TALE_CONFIG_DIR/providers/.',
+    );
+  }
+
+  const jsonFiles = entries.filter(
+    (e) =>
+      e.endsWith('.json') && !e.startsWith('.') && !e.endsWith('.secrets.json'),
+  );
+
+  if (jsonFiles.length === 0) {
+    throw new Error(
+      `No provider JSON files found in ${dir}. ` +
+        'Create at least one provider configuration file.',
+    );
+  }
+
+  const providers: ProviderWithSecrets[] = [];
+
+  for (const fileName of jsonFiles) {
+    const providerName = path.basename(fileName, '.json');
+    if (!validateProviderName(providerName)) continue;
+
+    const filePath = path.join(dir, fileName);
+    const result = await readJsonFile<ProviderJson>(
+      filePath,
+      MAX_FILE_SIZE_BYTES,
+      parseProviderJson,
+    );
+    if (!result.ok) continue;
+
+    const secretsPath = path.join(dir, `${providerName}.secrets.json`);
+    let secrets: ProviderSecrets;
+    try {
+      const raw = await decryptSecretsFile(secretsPath);
+      secrets = parseProviderSecrets(raw);
+    } catch (err) {
+      throw new Error(
+        `Failed to load secrets for provider "${providerName}": ${err instanceof Error ? err.message : String(err)}`,
+        { cause: err },
+      );
+    }
+
+    providers.push({ name: providerName, config: result.data, secrets });
+  }
+
+  if (providers.length === 0) {
+    throw new Error('No valid providers could be loaded.');
+  }
+
+  return providers;
+}
+
+// ---------------------------------------------------------------------------
+// Public CRUD actions (called from frontend)
+// ---------------------------------------------------------------------------
+
+export const readProvider = action({
+  args: { orgSlug: v.string(), providerName: v.string() },
+  returns: v.any(),
+  handler: async (ctx, args): Promise<ProviderReadResult> => {
+    const authUser = await authComponent.getAuthUser(ctx);
+    if (!authUser) throw new Error('Unauthenticated');
+    return readProviderFile(args.orgSlug, args.providerName);
+  },
+});
+
+export const listProviders = action({
+  args: { orgSlug: v.string() },
+  returns: v.any(),
+  handler: async (ctx, args) => {
+    const authUser = await authComponent.getAuthUser(ctx);
+    if (!authUser) throw new Error('Unauthenticated');
+
+    const dir = resolveProvidersDir(args.orgSlug);
+    let entries: string[];
+    try {
+      entries = await readdir(dir);
+    } catch {
+      return [];
+    }
+
+    const jsonFiles = entries.filter(
+      (e) =>
+        e.endsWith('.json') &&
+        !e.startsWith('.') &&
+        !e.endsWith('.secrets.json'),
+    );
+
+    const results = await Promise.all(
+      jsonFiles.map(async (fileName) => {
+        const name = providerNameFromFileName(fileName);
+        if (!validateProviderName(name)) return null;
+        const result = await readProviderFile(args.orgSlug, name);
+        if (result.ok) {
+          return {
+            name,
+            displayName: result.config.displayName,
+            description: result.config.description,
+            baseUrl: result.config.baseUrl,
+            modelCount: result.config.models.length,
+            models: result.config.models.map((m) => ({
+              id: m.id,
+              displayName: m.displayName,
+              tags: m.tags,
+            })),
+            i18n: result.config.i18n,
+          };
+        }
+        return { name, status: result.error, message: result.message };
+      }),
+    );
+
+    return results.filter(Boolean);
+  },
+});
+
+export const saveProvider = action({
+  args: { orgSlug: v.string(), providerName: v.string(), config: v.any() },
+  returns: v.object({ hash: v.string() }),
+  handler: async (ctx, args): Promise<{ hash: string }> => {
+    const authUser = await authComponent.getAuthUser(ctx);
+    if (!authUser) throw new Error('Unauthenticated');
+
+    if (!validateProviderName(args.providerName))
+      throw new Error(`Invalid provider name: ${args.providerName}`);
+    const config = providerJsonSchema.parse(args.config);
+    const content = serializeProviderJson(config);
+    const filePath = resolveProviderFilePath(args.orgSlug, args.providerName);
+    await atomicWrite(filePath, content);
+    return { hash: sha256(content) };
+  },
+});
+
+export const deleteProvider = action({
+  args: { orgSlug: v.string(), providerName: v.string() },
+  returns: v.null(),
+  handler: async (ctx, args): Promise<null> => {
+    const authUser = await authComponent.getAuthUser(ctx);
+    if (!authUser) throw new Error('Unauthenticated');
+    const filePath = resolveProviderFilePath(args.orgSlug, args.providerName);
+    const secretsPath = resolveProviderSecretsPath(
+      args.orgSlug,
+      args.providerName,
+    );
+    await unlink(filePath).catch(() => {});
+    await unlink(secretsPath).catch(() => {});
+    return null;
+  },
+});
+
+// ---------------------------------------------------------------------------
+// Internal actions for model resolution (return pure data, no instances)
+// ---------------------------------------------------------------------------
+
+/**
+ * Resolve provider data for a specific model ID.
+ * Returns serializable data that callers use to create provider instances locally.
+ */
+export const resolveModelData = internalAction({
+  args: {
+    modelId: v.string(),
+    orgSlug: v.optional(v.string()),
+  },
+  returns: v.any(),
+  handler: async (_ctx, args) => {
+    const orgSlug = args.orgSlug ?? 'default';
+    const providers = await loadAllProviders(orgSlug);
+
+    for (const provider of providers) {
+      const definition = provider.config.models.find(
+        (m) => m.id === args.modelId,
+      );
+      if (definition) {
+        return {
+          providerName: provider.name,
+          baseUrl: provider.config.baseUrl,
+          apiKey: provider.secrets.apiKey,
+          modelId: args.modelId,
+          supportsStructuredOutputs:
+            provider.config.supportsStructuredOutputs ?? false,
+        };
+      }
+    }
+
+    const allModelIds = providers.flatMap((p) =>
+      p.config.models.map((m) => m.id),
+    );
+    throw new Error(
+      `Model "${args.modelId}" not found in any provider. Available: ${allModelIds.join(', ')}`,
+    );
+  },
+});
+
+/**
+ * Resolve provider data for the first model matching a tag (chat/vision/embedding).
+ */
+export const resolveModelByTag = internalAction({
+  args: {
+    tag: v.string(),
+    orgSlug: v.optional(v.string()),
+  },
+  returns: v.any(),
+  handler: async (_ctx, args) => {
+    const orgSlug = args.orgSlug ?? 'default';
+    const providers = await loadAllProviders(orgSlug);
+    for (const provider of providers) {
+      const definition = provider.config.models.find((m) =>
+        (m.tags as readonly string[]).includes(args.tag),
+      );
+      if (definition) {
+        return {
+          providerName: provider.name,
+          baseUrl: provider.config.baseUrl,
+          apiKey: provider.secrets.apiKey,
+          modelId: definition.id,
+          dimensions: definition.dimensions,
+          supportsStructuredOutputs:
+            provider.config.supportsStructuredOutputs ?? false,
+        };
+      }
+    }
+
+    throw new Error(`No model with tag "${args.tag}" found in any provider.`);
+  },
+});
+
+/**
+ * Get the default model ID (first model marked default:true, or first model).
+ */
+export const getDefaultModelId = internalAction({
+  args: { orgSlug: v.optional(v.string()) },
+  returns: v.string(),
+  handler: async (_ctx, args) => {
+    const orgSlug = args.orgSlug ?? 'default';
+    const providers = await loadAllProviders(orgSlug);
+    for (const provider of providers) {
+      const defaultModel = provider.config.models.find((m) => m.default);
+      if (defaultModel) return defaultModel.id;
+    }
+    return providers[0].config.models[0].id;
+  },
+});
+
+/**
+ * Get all provider configs (public data only, no secrets).
+ */
+export const getAllProviderConfigs = action({
+  args: { orgSlug: v.string() },
+  returns: v.any(),
+  handler: async (ctx, args) => {
+    const authUser = await authComponent.getAuthUser(ctx);
+    if (!authUser) throw new Error('Unauthenticated');
+
+    const dir = resolveProvidersDir(args.orgSlug);
+    let entries: string[];
+    try {
+      entries = await readdir(dir);
+    } catch {
+      return [];
+    }
+
+    const jsonFiles = entries.filter(
+      (e) =>
+        e.endsWith('.json') &&
+        !e.startsWith('.') &&
+        !e.endsWith('.secrets.json'),
+    );
+
+    const results = await Promise.all(
+      jsonFiles.map(async (fileName) => {
+        const name = providerNameFromFileName(fileName);
+        if (!validateProviderName(name)) return null;
+        const result = await readProviderFile(args.orgSlug, name);
+        if (!result.ok) return null;
+        return {
+          providerName: name,
+          displayName: result.config.displayName,
+          description: result.config.description,
+          models: result.config.models.map((m) => ({
+            id: m.id,
+            displayName: m.displayName,
+            description: m.description,
+            tags: m.tags,
+          })),
+          i18n: result.config.i18n,
+        };
+      }),
+    );
+
+    return results.filter(Boolean);
+  },
+});
+
+// ---------------------------------------------------------------------------
+// Secret management actions
+// ---------------------------------------------------------------------------
+
+/**
+ * Save an API key for a provider by writing a SOPS-encrypted .secrets.json file.
+ */
+export const saveProviderSecret = action({
+  args: {
+    orgSlug: v.string(),
+    providerName: v.string(),
+    apiKey: v.string(),
+  },
+  returns: v.null(),
+  handler: async (ctx, args): Promise<null> => {
+    const authUser = await authComponent.getAuthUser(ctx);
+    if (!authUser) throw new Error('Unauthenticated');
+
+    if (!validateProviderName(args.providerName))
+      throw new Error(`Invalid provider name: ${args.providerName}`);
+
+    const secretsPath = resolveProviderSecretsPath(
+      args.orgSlug,
+      args.providerName,
+    );
+
+    const plaintext = JSON.stringify({ apiKey: args.apiKey }, null, 2) + '\n';
+    const { execSync } = await import('node:child_process');
+
+    await atomicWrite(secretsPath, plaintext);
+
+    try {
+      execSync(`sops -e -i "${secretsPath}"`, {
+        encoding: 'utf-8',
+        timeout: 10_000,
+        stdio: ['pipe', 'pipe', 'pipe'],
+      });
+    } catch (err) {
+      const { unlink: unlinkFile } = await import('node:fs/promises');
+      await unlinkFile(secretsPath).catch(() => {});
+      const message = err instanceof Error ? err.message : String(err);
+      throw new Error(
+        `Failed to encrypt secrets for "${args.providerName}": ${message}. ` +
+          'Ensure sops is installed and SOPS_AGE_KEY is set.',
+        { cause: err },
+      );
+    }
+
+    return null;
+  },
+});
+
+/**
+ * Check if a provider has a secrets file configured.
+ */
+export const hasProviderSecret = action({
+  args: {
+    orgSlug: v.string(),
+    providerName: v.string(),
+  },
+  returns: v.boolean(),
+  handler: async (ctx, args): Promise<boolean> => {
+    const authUser = await authComponent.getAuthUser(ctx);
+    if (!authUser) throw new Error('Unauthenticated');
+
+    const secretsPath = resolveProviderSecretsPath(
+      args.orgSlug,
+      args.providerName,
+    );
+
+    const { stat: statFile } = await import('node:fs/promises');
+    try {
+      await statFile(secretsPath);
+      return true;
+    } catch {
+      return false;
+    }
+  },
+});
diff --git a/services/platform/convex/providers/file_utils.ts b/services/platform/convex/providers/file_utils.ts
new file mode 100644
index 0000000000..138637c4e7
--- /dev/null
+++ b/services/platform/convex/providers/file_utils.ts
@@ -0,0 +1,108 @@
+'use node';
+
+import path from 'node:path';
+
+import type {
+  ProviderJson,
+  ProviderSecrets,
+} from '../../lib/shared/schemas/providers';
+
+import {
+  providerJsonSchema,
+  providerSecretsSchema,
+} from '../../lib/shared/schemas/providers';
+import { serializeJson, sha256, validateOrgSlug } from '../lib/file_io';
+import { validateProviderName } from './validators';
+
+export { sha256, validateProviderName };
+export type { ProviderJson, ProviderSecrets };
+
+const MAX_FILE_SIZE_BYTES = 256 * 1024;
+
+export type ProviderReadResult =
+  | { ok: true; config: ProviderJson; hash: string }
+  | {
+      ok: false;
+      error:
+        | 'not_found'
+        | 'corrupted'
+        | 'too_large'
+        | 'symlink'
+        | 'inaccessible';
+      message: string;
+    };
+
+export function providerNameFromFileName(fileName: string): string {
+  return path.basename(fileName, '.json');
+}
+
+export function serializeProviderJson(config: ProviderJson): string {
+  return serializeJson(config);
+}
+
+export function parseProviderJson(content: string): ProviderJson {
+  const parsed: unknown = JSON.parse(content);
+  const result = providerJsonSchema.safeParse(parsed);
+  if (!result.success) {
+    throw new Error(`Invalid provider JSON: ${result.error.message}`);
+  }
+  return result.data;
+}
+
+export function parseProviderSecrets(
+  data: Record<string, unknown>,
+): ProviderSecrets {
+  const result = providerSecretsSchema.safeParse(data);
+  if (!result.success) {
+    throw new Error(`Invalid provider secrets: ${result.error.message}`);
+  }
+  return result.data;
+}
+
+function getBaseDir(): string {
+  const dir = process.env.PROVIDERS_DIR;
+  if (dir) return dir;
+  const configDir = process.env.TALE_CONFIG_DIR;
+  if (configDir) return path.join(configDir, 'providers');
+  throw new Error(
+    'Neither TALE_CONFIG_DIR nor PROVIDERS_DIR environment variable is set.',
+  );
+}
+
+export function resolveProvidersDir(orgSlug: string): string {
+  if (!validateOrgSlug(orgSlug))
+    throw new Error(`Invalid org slug: ${orgSlug}`);
+  const baseDir = getBaseDir();
+  if (orgSlug === 'default') return baseDir;
+  return path.join(baseDir, orgSlug);
+}
+
+export function resolveProviderFilePath(
+  orgSlug: string,
+  providerName: string,
+): string {
+  if (!validateProviderName(providerName))
+    throw new Error(`Invalid provider name: ${providerName}`);
+  const dir = resolveProvidersDir(orgSlug);
+  const resolved = path.resolve(dir, `${providerName}.json`);
+  const expectedPrefix = path.resolve(dir);
+  if (
+    !resolved.startsWith(expectedPrefix + path.sep) &&
+    resolved !== expectedPrefix
+  ) {
+    throw new Error(`Path traversal detected: ${providerName}`);
+  }
+  return resolved;
+}
+
+export function resolveProviderSecretsPath(
+  orgSlug: string,
+  providerName: string,
+): string {
+  if (!validateProviderName(providerName))
+    throw new Error(`Invalid provider name: ${providerName}`);
+  const dir = resolveProvidersDir(orgSlug);
+  return path.resolve(dir, `${providerName}.secrets.json`);
+}
+
+export { MAX_FILE_SIZE_BYTES };
diff --git a/services/platform/convex/providers/validators.ts b/services/platform/convex/providers/validators.ts
new file mode 100644
index 0000000000..3c843eb4f9
--- /dev/null
+++ b/services/platform/convex/providers/validators.ts
@@ -0,0 +1,5 @@
+const PROVIDER_NAME_REGEX = /^[a-z0-9][a-z0-9_-]*$/;
+
+export function validateProviderName(name: string): boolean {
+  return PROVIDER_NAME_REGEX.test(name);
+}
diff --git a/services/platform/convex/workflow_engine/helpers/nodes/llm/execute_agent_with_tools.ts b/services/platform/convex/workflow_engine/helpers/nodes/llm/execute_agent_with_tools.ts
index 41b09d4ced..8c26fb2359 100644
--- a/services/platform/convex/workflow_engine/helpers/nodes/llm/execute_agent_with_tools.ts
+++ b/services/platform/convex/workflow_engine/helpers/nodes/llm/execute_agent_with_tools.ts
@@ -9,6 +9,8 @@
  * - JSON output with tools: NOT ALLOWED — split into two explicit LLM steps
  */
 
+import type { LanguageModelV3 } from '@ai-sdk/provider';
+
 import { Agent } from '@convex-dev/agent';
 import { z } from 'zod/v4';
 
@@ -95,6 +97,7 @@ export async function executeAgentWithTools(
     threadId?: string;
     stepSlug?: string;
     knowledgeFileIds?: string[];
+    languageModel: LanguageModelV3;
   },
 ): Promise<LLMExecutionResult> {
   if (config.outputFormat === 'json' && !config.outputSchema) {
@@ -148,6 +151,7 @@ export async function executeAgentWithTools(
       zodSchema,
       threadId,
       _args.userId,
+      _args.languageModel,
     );
   }
 
@@ -166,6 +170,7 @@ export async function executeAgentWithTools(
     prompts,
     threadId,
     _args.userId,
+    _args.languageModel,
   );
 }
 
@@ -199,7 +204,8 @@ async function executeJsonOutputWithoutTools(
   prompts: ProcessedPrompts,
   zodSchema: z.ZodType,
   threadId: string,
-  userId?: string,
+  userId: string | undefined,
+  languageModel: LanguageModelV3,
 ): Promise<LLMExecutionResult> {
   debugLog('executeJsonOutputWithoutTools START', {
     configName: config.name,
@@ -217,7 +223,7 @@ async function executeJsonOutputWithoutTools(
     components.agent,
     createAgentConfig({
       name: config.name,
-      model: config.model,
+      languageModel,
       outputFormat: config.outputFormat,
       instructions: prompts.systemPrompt,
     }),
@@ -250,7 +256,8 @@ async function executeTextOutput(
   config: NormalizedConfig,
   prompts: ProcessedPrompts,
   threadId: string,
-  userId?: string,
+  userId: string | undefined,
+  languageModel: LanguageModelV3,
 ): Promise<LLMExecutionResult> {
   debugLog('executeTextOutput START', {
     configName: config.name,
@@ -268,7 +275,7 @@ async function executeTextOutput(
     components.agent,
     createAgentConfig({
       name: config.name,
-      model: config.model,
+      languageModel,
       outputFormat: config.outputFormat,
       instructions: prompts.systemPrompt,
       // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- config.tools contains valid ToolName strings from workflow step configuration
diff --git a/services/platform/convex/workflow_engine/helpers/nodes/llm/execute_llm_node.ts b/services/platform/convex/workflow_engine/helpers/nodes/llm/execute_llm_node.ts
index 2adf4cf262..65d6d27531 100644
--- a/services/platform/convex/workflow_engine/helpers/nodes/llm/execute_llm_node.ts
+++ b/services/platform/convex/workflow_engine/helpers/nodes/llm/execute_llm_node.ts
@@ -5,10 +5,13 @@
  * Uses AI SDK with OpenAI provider and Agent SDK for tool integration.
  */
 
+import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
+
 import type { Id } from '../../../../_generated/dataModel';
 import type { ActionCtx } from '../../../../_generated/server';
 import type { StepExecutionResult, LLMNodeConfig } from '../../../types';
 
+import { internal } from '../../../../_generated/api';
 import { executeAgentWithTools } from './execute_agent_with_tools';
 import { createLLMResult } from './utils/create_llm_result';
 import { processPrompts } from './utils/process_prompts';
@@ -36,8 +39,29 @@ export async function executeLLMNode(
   threadId?: string,
   stepSlug?: string,
 ): Promise<StepExecutionResult> {
-  // 1. Validate and normalize configuration
-  const normalizedConfig = validateAndNormalizeConfig(config);
+  // 1. Resolve default model from provider files, then validate and normalize config
+  // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- resolveModelByTag returns v.any() but shape is guaranteed by provider file_actions contract
+  const chatModelData = (await ctx.runAction(
+    internal.providers.file_actions.resolveModelByTag,
+    { tag: 'chat' },
+  )) as {
+    providerName: string;
+    baseUrl: string;
+    apiKey: string;
+    modelId: string;
+    supportsStructuredOutputs: boolean;
+  };
+  const providerInstance = createOpenAICompatible({
+    name: chatModelData.providerName,
+    baseURL: chatModelData.baseUrl,
+    apiKey: chatModelData.apiKey,
+    supportsStructuredOutputs: chatModelData.supportsStructuredOutputs,
+  });
+  const languageModel = providerInstance.chatModel(chatModelData.modelId);
+  const normalizedConfig = validateAndNormalizeConfig(
+    config,
+    chatModelData.modelId,
+  );
 
   // 2. Process prompts with variable substitution
   const prompts = processPrompts(normalizedConfig, variables);
@@ -63,6 +87,7 @@ export async function executeLLMNode(
       stepSlug,
       knowledgeFileIds,
       userId,
+      languageModel,
     },
   );
 
diff --git a/services/platform/convex/workflow_engine/helpers/nodes/llm/utils/validate_and_normalize_config.ts b/services/platform/convex/workflow_engine/helpers/nodes/llm/utils/validate_and_normalize_config.ts
index 85a236cf5f..4d770497f4 100644
--- a/services/platform/convex/workflow_engine/helpers/nodes/llm/utils/validate_and_normalize_config.ts
+++ b/services/platform/convex/workflow_engine/helpers/nodes/llm/utils/validate_and_normalize_config.ts
@@ -7,13 +7,15 @@
 import type { LLMNodeConfig } from '../../../../types/nodes';
 import type { NormalizedConfig } from '../types';
 
-import { getDefaultModel } from '../../../../../lib/agent_runtime_config';
-
 /**
- * Validates and normalizes the LLM node configuration
+ * Validates and normalizes the LLM node configuration.
+ *
+ * Model resolution is deferred to the action layer: the caller passes a
+ * `defaultModel` string that was resolved via the provider file_actions.
  */
 export function validateAndNormalizeConfig(
   rawConfig: LLMNodeConfig | { llmNode: LLMNodeConfig },
+  defaultModel?: string,
 ): NormalizedConfig {
   // Support both shapes: config.llmNode or config itself
   const llmConfig: LLMNodeConfig =
@@ -23,7 +25,13 @@ export function validateAndNormalizeConfig(
     throw new Error('Invalid LLM node configuration: systemPrompt is required');
   }
 
-  const envModel = getDefaultModel();
+  if (!defaultModel) {
+    throw new Error(
+      'Invalid LLM node configuration: defaultModel must be provided by the caller',
+    );
+  }
+
+  const envModel = defaultModel;
 
   // Validate outputSchema if provided
   if (llmConfig.outputSchema) {
diff --git a/services/platform/convex/workflows/triggers/actions.ts b/services/platform/convex/workflows/triggers/actions.ts
index 8f593c23b7..6652dd3553 100644
--- a/services/platform/convex/workflows/triggers/actions.ts
+++ b/services/platform/convex/workflows/triggers/actions.ts
@@ -1,14 +1,14 @@
 'use node';
 
+import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
 import { generateObject } from 'ai';
 import { v } from 'convex/values';
 import { CronExpressionParser } from 'cron-parser';
 import { z } from 'zod/v4';
 
+import { internal } from '../../_generated/api';
 import { action } from '../../_generated/server';
 import { authComponent } from '../../auth';
-import { getFastModel } from '../../lib/agent_runtime_config';
-import { openai } from '../../lib/openai_provider';
 
 export const generateCronExpression = action({
   args: {
@@ -32,10 +32,27 @@ export const generateCronExpression = action({
       throw new Error('Please enter a schedule description.');
     }
 
-    const model = getFastModel();
+    // Resolve chat model from provider files
+    // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- resolveModelByTag returns v.any() but shape is guaranteed by provider file_actions contract
+    const modelData = (await ctx.runAction(
+      internal.providers.file_actions.resolveModelByTag,
+      { tag: 'chat' },
+    )) as {
+      providerName: string;
+      baseUrl: string;
+      apiKey: string;
+      modelId: string;
+      supportsStructuredOutputs: boolean;
+    };
+    const providerInstance = createOpenAICompatible({
+      name: modelData.providerName,
+      baseURL: modelData.baseUrl,
+      apiKey: modelData.apiKey,
+      supportsStructuredOutputs: modelData.supportsStructuredOutputs,
+    });
 
     const result = await generateObject({
-      model: openai.chatModel(model),
+      model: providerInstance.chatModel(modelData.modelId),
       temperature: 0.1,
       schema: z.object({
         cronExpression: z
diff --git a/services/platform/lib/config-watcher.ts b/services/platform/lib/config-watcher.ts
index 1118864a68..adc3094d35 100644
--- a/services/platform/lib/config-watcher.ts
+++ b/services/platform/lib/config-watcher.ts
@@ -2,7 +2,7 @@ import chokidar from 'chokidar';
 import { relative } from 'node:path';
 
 interface ConfigChangeEvent {
-  type: 'agents' | 'workflows' | 'integrations' | 'branding';
+  type: 'agents' | 'workflows' | 'integrations' | 'providers' | 'branding';
   orgSlug?: string;
   slug?: string;
 }
@@ -35,6 +35,7 @@ function parseConfigChange(relativePath: string): ConfigChangeEvent | null {
     agents: 'agents',
     workflows: 'workflows',
     integrations: 'integrations',
+    providers: 'providers',
   };
 
   const type = typeMap[topDir];
@@ -69,6 +70,12 @@ function parseConfigChange(relativePath: string): ConfigChangeEvent | null {
     return { type, orgSlug, slug };
   }
 
+  if (type === 'providers') {
+    // providers/[@org/]name.json
+    const filename = rest[0];
+    return { type, orgSlug, slug: filename.replace(/\.json$/, '') };
+  }
+
   return null;
 }
 
@@ -91,8 +98,9 @@ export function createConfigWatcher(configDir: string): ConfigWatcher {
   watcher.on('all', (_eventName, filePath) => {
     const rel = relative(configDir, filePath);
 
-    // Only react to JSON file changes
+    // Only react to JSON file changes; ignore secret sidecar files
     if (!rel.endsWith('.json')) return;
+    if (rel.endsWith('.secrets.json')) return;
 
     const event = parseConfigChange(rel);
     if (!event) return;
diff --git a/services/platform/lib/shared/schemas/agents.ts b/services/platform/lib/shared/schemas/agents.ts
index 46cf23f6d6..d4a4134160 100644
--- a/services/platform/lib/shared/schemas/agents.ts
+++ b/services/platform/lib/shared/schemas/agents.ts
@@ -1,9 +1,5 @@
 import { z } from 'zod/v4';
 
-const modelPresetLiterals = ['fast', 'standard', 'advanced'] as const;
-export const modelPresetSchema = z.enum(modelPresetLiterals);
-export type ModelPreset = z.infer<typeof modelPresetSchema>;
-
 const retrievalModeLiterals = ['off', 'tool', 'context', 'both'] as const;
 type RetrievalMode = (typeof retrievalModeLiterals)[number];
 
@@ -35,8 +31,7 @@ export const agentJsonSchema = z.object({
   integrationBindings: z.array(z.string()).optional(),
   delegates: z.array(z.string()).optional(),
   workflows: z.array(z.string()).optional(),
-  modelPreset: modelPresetSchema.optional(),
-  modelId: z.string().optional(),
+  supportedModels: z.array(z.string().min(1)).min(1),
   knowledgeMode: retrievalModeSchema.optional(),
   webSearchMode: retrievalModeSchema.optional(),
   includeOrgKnowledge: z.boolean().optional(),
diff --git a/services/platform/lib/shared/schemas/providers.ts b/services/platform/lib/shared/schemas/providers.ts
new file mode 100644
index 0000000000..1bd6792c87
--- /dev/null
+++ b/services/platform/lib/shared/schemas/providers.ts
@@ -0,0 +1,49 @@
+import { z } from 'zod/v4';
+
+const modelTagLiterals = ['chat', 'vision', 'embedding'] as const;
+export const modelTagSchema = z.enum(modelTagLiterals);
+export type ModelTag = z.infer<typeof modelTagSchema>;
+
+const modelDefinitionSchema = z.object({
+  id: z.string().min(1).max(200),
+  displayName: z.string().min(1).max(200),
+  description: z.string().max(1000).optional(),
+  tags: z.array(modelTagSchema).min(1),
+  default: z.boolean().optional(),
+  dimensions: z.number().int().positive().optional(),
+});
+
+export type ModelDefinition = z.infer<typeof modelDefinitionSchema>;
+
+const translatableModelFieldsSchema = z.object({
+  displayName: z.string().min(1).max(200).optional(),
+  description: z.string().max(1000).optional(),
+});
+
+const translatableProviderFieldsSchema = z.object({
+  displayName: z.string().min(1).max(200).optional(),
+  description: z.string().max(1000).optional(),
+  models: z.record(z.string(), translatableModelFieldsSchema).optional(),
+});
+
+export const providerJsonSchema = z.object({
+  displayName: z.string().min(1).max(200),
+  description: z.string().max(1000).optional(),
+  baseUrl: z.string().url(),
+  supportsStructuredOutputs: z.boolean().optional(),
+  models: z.array(modelDefinitionSchema).min(1),
+  i18n: z
+    .record(
+      z.string().regex(/^[a-z]{2}(-[A-Z]{2})?$/),
+      translatableProviderFieldsSchema,
+    )
+    .optional(),
+});
+
+export type ProviderJson = z.infer<typeof providerJsonSchema>;
+
+export const providerSecretsSchema = z.object({
+  apiKey: z.string().min(1),
+});
+
+export type ProviderSecrets = z.infer<typeof providerSecretsSchema>;
diff --git a/services/platform/messages/en.json b/services/platform/messages/en.json
index 4799c2bfce..910c7c90b2 100644
--- a/services/platform/messages/en.json
+++ b/services/platform/messages/en.json
@@ -228,6 +228,7 @@
     "organization": "Organization",
     "teams": "Teams",
     "integrations": "Integrations",
+    "providers": "Providers",
     "apiKeys": "API keys",
     "branding": "Branding",
     "apiDocs": "API docs",
@@ -655,7 +656,7 @@
       "columns": {
         "displayName": "Name",
         "active": "Active",
-        "modelPreset": "Model",
+        "model": "Model",
         "tools": "Tools",
         "version": "Version",
         "team": "Team",
@@ -1392,6 +1393,51 @@
         "roleDisabled": "Disabled"
       }
     },
+    "providers": {
+      "title": "Providers",
+      "description": "Manage LLM providers and their models",
+      "addProvider": "Add provider",
+      "editProvider": "Edit provider",
+      "deleteProvider": "Delete provider",
+      "deleteConfirm": "Are you sure you want to delete this provider? This action cannot be undone.",
+      "apiKey": "API Key",
+      "apiKeyConfigured": "API key configured",
+      "apiKeyNotConfigured": "API key not configured",
+      "models": "Models",
+      "addModel": "Add model",
+      "modelId": "Model ID",
+      "displayName": "Display name",
+      "description_field": "Description",
+      "baseUrl": "Base URL",
+      "tags": "Tags",
+      "defaultModel": "Default",
+      "dimensions": "Dimensions",
+      "saved": "Provider saved",
+      "deleted": "Provider deleted",
+      "secretSaved": "API key saved",
+      "name": "Provider name",
+      "namePlaceholder": "e.g., openai",
+      "nameHelp": "Lowercase letters, numbers, and hyphens only.",
+      "namePatternError": "Must start with a letter and contain only lowercase letters, numbers, and hyphens.",
+      "displayNamePlaceholder": "e.g., OpenAI",
+      "descriptionPlaceholder": "e.g., OpenAI GPT models",
+      "baseUrlPlaceholder": "e.g., https://api.openai.com/v1",
+      "modelIdPlaceholder": "e.g., gpt-4o",
+      "modelDisplayNamePlaceholder": "e.g., GPT-4o",
+      "modelDescriptionPlaceholder": "e.g., Multimodal model with vision capabilities",
+      "dimensionsPlaceholder": "e.g., 1536",
+      "noProviders": "No providers configured",
+      "noProvidersDescription": "Add an LLM provider to get started with AI features.",
+      "general": "General",
+      "saveFailed": "Failed to save provider",
+      "deleteFailed": "Failed to delete provider",
+      "secretSaveFailed": "Failed to save API key",
+      "createFailed": "Failed to create provider",
+      "created": "Provider created",
+      "modelCount": "{count, plural, one {# model} other {# models}}",
+      "removeModel": "Remove model",
+      "apiKeyPlaceholder": "Enter API key"
+    },
     "form": {
       "name": "Name",
       "namePlaceholder": "Enter full name",
@@ -3239,6 +3285,10 @@
       "title": "Integrations",
       "description": "Connect third-party services."
     },
+    "providers": {
+      "title": "Providers",
+      "description": "Manage LLM providers and their models."
+    },
     "logs": {
       "title": "Logs",
       "description": "View system logs and activity."
diff --git a/services/rag/Dockerfile b/services/rag/Dockerfile
index 72a12d0c30..35fa59ea2a 100644
--- a/services/rag/Dockerfile
+++ b/services/rag/Dockerfile
@@ -13,11 +13,13 @@ WORKDIR /app
 # - curl: Health checks and downloads
 # - build-essential: Compilation for native Python packages
 # - libpq-dev: PostgreSQL client library for asyncpg
+ARG SOPS_VERSION=3.9.4
 RUN apt-get update && apt-get install -y --no-install-recommends \
     curl \
     build-essential \
     libpq-dev \
-    && rm -rf /var/lib/apt/lists/*
+    && rm -rf /var/lib/apt/lists/* \
+    && curl -fsSL "https://github.com/getsops/sops/releases/download/v${SOPS_VERSION}/sops-v${SOPS_VERSION}.linux.amd64" -o /usr/local/bin/sops && chmod +x /usr/local/bin/sops
 
 # Install uv for faster package management
 COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

From 05d16829a1b1f1d22db4deb2aedd5b7c4ac70222 Mon Sep 17 00:00:00 2001
From: larryro <371767072@qq.com>
Date: Thu, 2 Apr 2026 23:08:02 +0800
Subject: [PATCH 02/13] feat(platform): add nested provider routes with table
 and detail views

---
 .../providers/components/providers-table.tsx  | 109 +++++
 .../hooks/use-provider-config-context.tsx     | 121 ++++++
 .../hooks/use-providers-table-config.tsx      |  66 +++
 services/platform/app/routeTree.gen.ts        |  69 ++-
 .../dashboard/$id/settings/providers.tsx      |  11 +-
 .../$id/settings/providers/$providerName.tsx  | 403 ++++++++++++++++++
 .../$id/settings/providers/index.tsx          |  12 +
 services/platform/messages/en.json            |  12 +
 8 files changed, 790 insertions(+), 13 deletions(-)
 create mode 100644 services/platform/app/features/settings/providers/components/providers-table.tsx
 create mode 100644 services/platform/app/features/settings/providers/hooks/use-provider-config-context.tsx
 create mode 100644 services/platform/app/features/settings/providers/hooks/use-providers-table-config.tsx
 create mode 100644 services/platform/app/routes/dashboard/$id/settings/providers/$providerName.tsx
 create mode 100644 services/platform/app/routes/dashboard/$id/settings/providers/index.tsx

diff --git a/services/platform/app/features/settings/providers/components/providers-table.tsx b/services/platform/app/features/settings/providers/components/providers-table.tsx
new file mode 100644
index 0000000000..c67b797ead
--- /dev/null
+++ b/services/platform/app/features/settings/providers/components/providers-table.tsx
@@ -0,0 +1,109 @@
+'use client';
+
+import type { Row } from '@tanstack/react-table';
+
+import { useQueryClient } from '@tanstack/react-query';
+import { useNavigate } from '@tanstack/react-router';
+import { Plus, Server } from 'lucide-react';
+import { useCallback, useMemo, useState } from 'react';
+
+import { DataTable } from '@/app/components/ui/data-table/data-table';
+import { Button } from '@/app/components/ui/primitives/button';
+import { useListPage } from '@/app/hooks/use-list-page';
+import { useT } from '@/lib/i18n/client';
+
+import { useListProviders } from '../hooks/queries';
+import { useProvidersTableConfig } from '../hooks/use-providers-table-config';
+import { ProviderAddDialog } from './provider-add-dialog';
+
+export interface ProviderRow {
+  name: string;
+  displayName: string;
+  description?: string;
+  baseUrl?: string;
+  modelCount?: number;
+}
+
+interface ProvidersTableProps {
+  organizationId: string;
+}
+
+export function ProvidersTable({ organizationId }: ProvidersTableProps) {
+  const { t } = useT('settings');
+  const { t: tEmpty } = useT('emptyStates');
+  const navigate = useNavigate();
+  const queryClient = useQueryClient();
+  const { providers: rawProviders, isLoading } = useListProviders('default');
+  const [addDialogOpen, setAddDialogOpen] = useState(false);
+
+  const providers = useMemo(() => {
+    if (!rawProviders) return [];
+    const valid: ProviderRow[] = [];
+    for (const p of rawProviders) {
+      if (p && 'displayName' in p && typeof p.displayName === 'string') {
+        valid.push({
+          name: p.name,
+          displayName: p.displayName,
+          description: p.description,
+          baseUrl: p.baseUrl,
+          modelCount: p.modelCount,
+        });
+      }
+    }
+    return valid;
+  }, [rawProviders]);
+
+  const invalidateProviders = useCallback(() => {
+    void queryClient.invalidateQueries({ queryKey: ['config', 'providers'] });
+  }, [queryClient]);
+
+  const { columns, searchPlaceholder, stickyLayout, pageSize } =
+    useProvidersTableConfig({ onDeleted: invalidateProviders });
+
+  const handleRowClick = useCallback(
+    (row: Row<ProviderRow>) => {
+      void navigate({
+        to: '/dashboard/$id/settings/providers/$providerName',
+        params: { id: organizationId, providerName: row.original.name },
+      });
+    },
+    [navigate, organizationId],
+  );
+
+  const list = useListPage<ProviderRow>({
+    dataSource: { type: 'query', data: isLoading ? undefined : providers },
+    pageSize,
+    search: {
+      fields: ['displayName', 'name'],
+      placeholder: searchPlaceholder,
+    },
+  });
+
+  return (
+    <>
+      <DataTable
+        className="p-4"
+        {...list.tableProps}
+        columns={columns}
+        stickyLayout={stickyLayout}
+        onRowClick={handleRowClick}
+        actionMenu={
+          <Button onClick={() => setAddDialogOpen(true)}>
+            <Plus className="mr-1.5 size-4" />
+            {t('providers.addProvider')}
+          </Button>
+        }
+        emptyState={{
+          icon: Server,
+          title: tEmpty('providers.title'),
+          description: tEmpty('providers.description'),
+        }}
+      />
+      <ProviderAddDialog
+        open={addDialogOpen}
+        onOpenChange={setAddDialogOpen}
+        organizationId={organizationId}
+      />
+    </>
+  );
+}
diff --git a/services/platform/app/features/settings/providers/hooks/use-provider-config-context.tsx b/services/platform/app/features/settings/providers/hooks/use-provider-config-context.tsx
new file mode 100644
index 0000000000..c416f8ab12
--- /dev/null
+++ b/services/platform/app/features/settings/providers/hooks/use-provider-config-context.tsx
@@ -0,0 +1,121 @@
+'use client';
+
+import {
+  createContext,
+  useCallback,
+  useContext,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from 'react';
+
+import type { ProviderJson } from '@/lib/shared/schemas/providers';
+
+interface ProviderConfigContextValue {
+  providerName: string;
+  config: ProviderJson;
+  initialConfig: ProviderJson;
+  isDirty: boolean;
+  isSaving: boolean;
+  updateConfig: (partial: Partial<ProviderJson>) => void;
+  resetConfig: () => void;
+  markSaving: (saving: boolean) => void;
+  overrideConfig: (config: ProviderJson) => void;
+}
+
+const ProviderConfigContext = createContext<ProviderConfigContextValue | null>(
+  null,
+);
+
+export function useProviderConfig() {
+  const ctx = useContext(ProviderConfigContext);
+  if (!ctx) {
+    throw new Error(
+      'useProviderConfig must be used within ProviderConfigProvider',
+    );
+  }
+  return ctx;
+}
+
+interface ProviderConfigProviderProps {
+  providerName: string;
+  initialConfig: ProviderJson;
+  children: React.ReactNode;
+}
+
+export function ProviderConfigProvider({
+  providerName,
+  initialConfig,
+  children,
+}: ProviderConfigProviderProps) {
+  const [config, setConfig] = useState<ProviderJson>(initialConfig);
+  const [isSaving, setIsSaving] = useState(false);
+  const initialRef = useRef(initialConfig);
+  const configRef = useRef(config);
+  configRef.current = config;
+
+  useEffect(() => {
+    const hasUnsavedEdits =
+      JSON.stringify(configRef.current) !== JSON.stringify(initialRef.current);
+    if (!hasUnsavedEdits) {
+      setConfig(initialConfig);
+      initialRef.current = initialConfig;
+    }
+  }, [initialConfig]);
+
+  const isDirty = useMemo(
+    () => JSON.stringify(config) !== JSON.stringify(initialRef.current),
+    [config],
+  );
+
+  const updateConfig = useCallback((partial: Partial<ProviderJson>) => {
+    setConfig((prev) => ({ ...prev, ...partial }));
+  }, []);
+
+  const resetConfig = useCallback(() => {
+    setConfig(initialRef.current);
+  }, []);
+
+  const markSaving = useCallback((saving: boolean) => {
+    setIsSaving(saving);
+    if (!saving) {
+      initialRef.current = configRef.current;
+    }
+  }, []);
+
+  const overrideConfig = useCallback((next: ProviderJson) => {
+    setConfig(next);
+    initialRef.current = next;
+  }, []);
+
+  const value = useMemo<ProviderConfigContextValue>(
+    () => ({
+      providerName,
+      config,
+      initialConfig: initialRef.current,
+      isDirty,
+      isSaving,
+      updateConfig,
+      resetConfig,
+      markSaving,
+      overrideConfig,
+    }),
+    [
+      providerName,
+      config,
+      isDirty,
+      isSaving,
+      updateConfig,
+      resetConfig,
+      markSaving,
+      overrideConfig,
+    ],
+  );
+
+  return (
+    <ProviderConfigContext.Provider value={value}>
+      {children}
+    </ProviderConfigContext.Provider>
+  );
+}
diff --git a/services/platform/app/features/settings/providers/hooks/use-providers-table-config.tsx b/services/platform/app/features/settings/providers/hooks/use-providers-table-config.tsx
new file mode 100644
index 0000000000..6a76072fb5
--- /dev/null
+++ b/services/platform/app/features/settings/providers/hooks/use-providers-table-config.tsx
@@ -0,0 +1,66 @@
+'use client';
+
+import type { ColumnDef } from '@tanstack/react-table';
+
+import { useMemo } from 'react';
+
+import { Badge } from '@/app/components/ui/feedback/badge';
+import { Text } from '@/app/components/ui/typography/text';
+import { useT } from '@/lib/i18n/client';
+
+import type { ProviderRow } from '../components/providers-table';
+
+interface ProvidersTableConfig {
+  columns: ColumnDef<ProviderRow>[];
+  searchPlaceholder: string;
+  stickyLayout: boolean;
+  pageSize: number;
+}
+
+export function useProvidersTableConfig(_opts: {
+  onDeleted?: () => void;
+}): ProvidersTableConfig {
+  const { t } = useT('settings');
+
+  const columns = useMemo<ColumnDef<ProviderRow>[]>(
+    () => [
+      {
+        id: 'displayName',
+        header: t('providers.displayName'),
+        cell: ({ row }) => (
+          <Text as="span" variant="label">
+            {row.original.displayName}
+          </Text>
+        ),
+        size: 200,
+      },
+      {
+        id: 'baseUrl',
+        header: t('providers.baseUrl'),
+        cell: ({ row }) => (
+          <Text as="span" variant="muted" className="max-w-[300px] truncate">
+            {row.original.baseUrl}
+          </Text>
+        ),
+        size: 300,
+      },
+      {
+        id: 'models',
+        header: t('providers.models'),
+        meta: { skeleton: { type: 'badge' } },
+        cell: ({ row }) => (
+          <Badge variant="outline">{row.original.modelCount ?? 0} models</Badge>
+        ),
+        size: 120,
+      },
+    ],
+    [t],
+  );
+
+  return {
+    columns,
+    searchPlaceholder: t('providers.searchProvider'),
+    stickyLayout: false,
+    pageSize: 50,
+  };
+}
diff --git a/services/platform/app/routeTree.gen.ts b/services/platform/app/routeTree.gen.ts
index 5d3017cc7a..425f901af3 100644
--- a/services/platform/app/routeTree.gen.ts
+++ b/services/platform/app/routeTree.gen.ts
@@ -49,7 +49,9 @@ import { Route as DashboardIdKnowledgeVendorsRouteImport } from './routes/dashbo
 import { Route as DashboardIdKnowledgeProductsRouteImport } from './routes/dashboard/$id/_knowledge/products'
 import { Route as DashboardIdKnowledgeDocumentsRouteImport } from './routes/dashboard/$id/_knowledge/documents'
 import { Route as DashboardIdKnowledgeCustomersRouteImport } from './routes/dashboard/$id/_knowledge/customers'
+import { Route as DashboardIdSettingsProvidersIndexRouteImport } from './routes/dashboard/$id/settings/providers/index'
 import { Route as DashboardIdAgentsAgentIdIndexRouteImport } from './routes/dashboard/$id/agents/$agentId/index'
+import { Route as DashboardIdSettingsProvidersProviderNameRouteImport } from './routes/dashboard/$id/settings/providers/$providerName'
 import { Route as DashboardIdAutomationsAmIdTriggersRouteImport } from './routes/dashboard/$id/automations/$amId/triggers'
 import { Route as DashboardIdAutomationsAmIdExecutionsRouteImport } from './routes/dashboard/$id/automations/$amId/executions'
 import { Route as DashboardIdAutomationsAmIdConfigurationRouteImport } from './routes/dashboard/$id/automations/$amId/configuration'
@@ -278,12 +280,24 @@ const DashboardIdKnowledgeCustomersRoute =
     path: '/customers',
     getParentRoute: () => DashboardIdKnowledgeRoute,
   } as any)
+const DashboardIdSettingsProvidersIndexRoute =
+  DashboardIdSettingsProvidersIndexRouteImport.update({
+    id: '/',
+    path: '/',
+    getParentRoute: () => DashboardIdSettingsProvidersRoute,
+  } as any)
 const DashboardIdAgentsAgentIdIndexRoute =
   DashboardIdAgentsAgentIdIndexRouteImport.update({
     id: '/',
     path: '/',
     getParentRoute: () => DashboardIdAgentsAgentIdRoute,
   } as any)
+const DashboardIdSettingsProvidersProviderNameRoute =
+  DashboardIdSettingsProvidersProviderNameRouteImport.update({
+    id: '/$providerName',
+    path: '/$providerName',
+    getParentRoute: () => DashboardIdSettingsProvidersRoute,
+  } as any)
 const DashboardIdAutomationsAmIdTriggersRoute =
   DashboardIdAutomationsAmIdTriggersRouteImport.update({
     id: '/triggers',
@@ -372,7 +386,7 @@ export interface FileRoutesByFullPath {
   '/dashboard/$id/settings/integrations': typeof DashboardIdSettingsIntegrationsRoute
   '/dashboard/$id/settings/logs': typeof DashboardIdSettingsLogsRoute
   '/dashboard/$id/settings/organization': typeof DashboardIdSettingsOrganizationRoute
-  '/dashboard/$id/settings/providers': typeof DashboardIdSettingsProvidersRoute
+  '/dashboard/$id/settings/providers': typeof DashboardIdSettingsProvidersRouteWithChildren
   '/dashboard/$id/settings/teams': typeof DashboardIdSettingsTeamsRoute
   '/dashboard/$id/agents/': typeof DashboardIdAgentsIndexRoute
   '/dashboard/$id/automations/': typeof DashboardIdAutomationsIndexRoute
@@ -387,7 +401,9 @@ export interface FileRoutesByFullPath {
   '/dashboard/$id/automations/$amId/configuration': typeof DashboardIdAutomationsAmIdConfigurationRoute
   '/dashboard/$id/automations/$amId/executions': typeof DashboardIdAutomationsAmIdExecutionsRoute
   '/dashboard/$id/automations/$amId/triggers': typeof DashboardIdAutomationsAmIdTriggersRoute
+  '/dashboard/$id/settings/providers/$providerName': typeof DashboardIdSettingsProvidersProviderNameRoute
   '/dashboard/$id/agents/$agentId/': typeof DashboardIdAgentsAgentIdIndexRoute
+  '/dashboard/$id/settings/providers/': typeof DashboardIdSettingsProvidersIndexRoute
 }
 export interface FileRoutesByTo {
   '/': typeof IndexRoute
@@ -415,7 +431,6 @@ export interface FileRoutesByTo {
   '/dashboard/$id/settings/integrations': typeof DashboardIdSettingsIntegrationsRoute
   '/dashboard/$id/settings/logs': typeof DashboardIdSettingsLogsRoute
   '/dashboard/$id/settings/organization': typeof DashboardIdSettingsOrganizationRoute
-  '/dashboard/$id/settings/providers': typeof DashboardIdSettingsProvidersRoute
   '/dashboard/$id/settings/teams': typeof DashboardIdSettingsTeamsRoute
   '/dashboard/$id/agents': typeof DashboardIdAgentsIndexRoute
   '/dashboard/$id/automations': typeof DashboardIdAutomationsIndexRoute
@@ -430,7 +445,9 @@ export interface FileRoutesByTo {
   '/dashboard/$id/automations/$amId/configuration': typeof DashboardIdAutomationsAmIdConfigurationRoute
   '/dashboard/$id/automations/$amId/executions': typeof DashboardIdAutomationsAmIdExecutionsRoute
   '/dashboard/$id/automations/$amId/triggers': typeof DashboardIdAutomationsAmIdTriggersRoute
+  '/dashboard/$id/settings/providers/$providerName': typeof DashboardIdSettingsProvidersProviderNameRoute
   '/dashboard/$id/agents/$agentId': typeof DashboardIdAgentsAgentIdIndexRoute
+  '/dashboard/$id/settings/providers': typeof DashboardIdSettingsProvidersIndexRoute
 }
 export interface FileRoutesById {
   __root__: typeof rootRouteImport
@@ -468,7 +485,7 @@ export interface FileRoutesById {
   '/dashboard/$id/settings/integrations': typeof DashboardIdSettingsIntegrationsRoute
   '/dashboard/$id/settings/logs': typeof DashboardIdSettingsLogsRoute
   '/dashboard/$id/settings/organization': typeof DashboardIdSettingsOrganizationRoute
-  '/dashboard/$id/settings/providers': typeof DashboardIdSettingsProvidersRoute
+  '/dashboard/$id/settings/providers': typeof DashboardIdSettingsProvidersRouteWithChildren
   '/dashboard/$id/settings/teams': typeof DashboardIdSettingsTeamsRoute
   '/dashboard/$id/agents/': typeof DashboardIdAgentsIndexRoute
   '/dashboard/$id/automations/': typeof DashboardIdAutomationsIndexRoute
@@ -483,7 +500,9 @@ export interface FileRoutesById {
   '/dashboard/$id/automations/$amId/configuration': typeof DashboardIdAutomationsAmIdConfigurationRoute
   '/dashboard/$id/automations/$amId/executions': typeof DashboardIdAutomationsAmIdExecutionsRoute
   '/dashboard/$id/automations/$amId/triggers': typeof DashboardIdAutomationsAmIdTriggersRoute
+  '/dashboard/$id/settings/providers/$providerName': typeof DashboardIdSettingsProvidersProviderNameRoute
   '/dashboard/$id/agents/$agentId/': typeof DashboardIdAgentsAgentIdIndexRoute
+  '/dashboard/$id/settings/providers/': typeof DashboardIdSettingsProvidersIndexRoute
 }
 export interface FileRouteTypes {
   fileRoutesByFullPath: FileRoutesByFullPath
@@ -535,7 +554,9 @@ export interface FileRouteTypes {
     | '/dashboard/$id/automations/$amId/configuration'
     | '/dashboard/$id/automations/$amId/executions'
     | '/dashboard/$id/automations/$amId/triggers'
+    | '/dashboard/$id/settings/providers/$providerName'
     | '/dashboard/$id/agents/$agentId/'
+    | '/dashboard/$id/settings/providers/'
   fileRoutesByTo: FileRoutesByTo
   to:
     | '/'
@@ -563,7 +584,6 @@ export interface FileRouteTypes {
     | '/dashboard/$id/settings/integrations'
     | '/dashboard/$id/settings/logs'
     | '/dashboard/$id/settings/organization'
-    | '/dashboard/$id/settings/providers'
     | '/dashboard/$id/settings/teams'
     | '/dashboard/$id/agents'
     | '/dashboard/$id/automations'
@@ -578,7 +598,9 @@ export interface FileRouteTypes {
     | '/dashboard/$id/automations/$amId/configuration'
     | '/dashboard/$id/automations/$amId/executions'
     | '/dashboard/$id/automations/$amId/triggers'
+    | '/dashboard/$id/settings/providers/$providerName'
     | '/dashboard/$id/agents/$agentId'
+    | '/dashboard/$id/settings/providers'
   id:
     | '__root__'
     | '/'
@@ -630,7 +652,9 @@ export interface FileRouteTypes {
     | '/dashboard/$id/automations/$amId/configuration'
     | '/dashboard/$id/automations/$amId/executions'
     | '/dashboard/$id/automations/$amId/triggers'
+    | '/dashboard/$id/settings/providers/$providerName'
     | '/dashboard/$id/agents/$agentId/'
+    | '/dashboard/$id/settings/providers/'
   fileRoutesById: FileRoutesById
 }
 export interface RootRouteChildren {
@@ -923,6 +947,13 @@ declare module '@tanstack/react-router' {
       preLoaderRoute: typeof DashboardIdKnowledgeCustomersRouteImport
       parentRoute: typeof DashboardIdKnowledgeRoute
     }
+    '/dashboard/$id/settings/providers/': {
+      id: '/dashboard/$id/settings/providers/'
+      path: '/'
+      fullPath: '/dashboard/$id/settings/providers/'
+      preLoaderRoute: typeof DashboardIdSettingsProvidersIndexRouteImport
+      parentRoute: typeof DashboardIdSettingsProvidersRoute
+    }
     '/dashboard/$id/agents/$agentId/': {
       id: '/dashboard/$id/agents/$agentId/'
       path: '/'
@@ -930,6 +961,13 @@ declare module '@tanstack/react-router' {
       preLoaderRoute: typeof DashboardIdAgentsAgentIdIndexRouteImport
       parentRoute: typeof DashboardIdAgentsAgentIdRoute
     }
+    '/dashboard/$id/settings/providers/$providerName': {
+      id: '/dashboard/$id/settings/providers/$providerName'
+      path: '/$providerName'
+      fullPath: '/dashboard/$id/settings/providers/$providerName'
+      preLoaderRoute: typeof DashboardIdSettingsProvidersProviderNameRouteImport
+      parentRoute: typeof DashboardIdSettingsProvidersRoute
+    }
     '/dashboard/$id/automations/$amId/triggers': {
       id: '/dashboard/$id/automations/$amId/triggers'
       path: '/triggers'
@@ -1136,6 +1174,24 @@ const DashboardIdConversationsRouteWithChildren =
     DashboardIdConversationsRouteChildren,
   )
 
+interface DashboardIdSettingsProvidersRouteChildren {
+  DashboardIdSettingsProvidersProviderNameRoute: typeof DashboardIdSettingsProvidersProviderNameRoute
+  DashboardIdSettingsProvidersIndexRoute: typeof DashboardIdSettingsProvidersIndexRoute
+}
+
+const DashboardIdSettingsProvidersRouteChildren: DashboardIdSettingsProvidersRouteChildren =
+  {
+    DashboardIdSettingsProvidersProviderNameRoute:
+      DashboardIdSettingsProvidersProviderNameRoute,
+    DashboardIdSettingsProvidersIndexRoute:
+      DashboardIdSettingsProvidersIndexRoute,
+  }
+
+const DashboardIdSettingsProvidersRouteWithChildren =
+  DashboardIdSettingsProvidersRoute._addFileChildren(
+    DashboardIdSettingsProvidersRouteChildren,
+  )
+
 interface DashboardIdSettingsRouteChildren {
   DashboardIdSettingsAccountRoute: typeof DashboardIdSettingsAccountRoute
   DashboardIdSettingsAgentsRoute: typeof DashboardIdSettingsAgentsRoute
@@ -1144,7 +1200,7 @@ interface DashboardIdSettingsRouteChildren {
   DashboardIdSettingsIntegrationsRoute: typeof DashboardIdSettingsIntegrationsRoute
   DashboardIdSettingsLogsRoute: typeof DashboardIdSettingsLogsRoute
   DashboardIdSettingsOrganizationRoute: typeof DashboardIdSettingsOrganizationRoute
-  DashboardIdSettingsProvidersRoute: typeof DashboardIdSettingsProvidersRoute
+  DashboardIdSettingsProvidersRoute: typeof DashboardIdSettingsProvidersRouteWithChildren
   DashboardIdSettingsTeamsRoute: typeof DashboardIdSettingsTeamsRoute
   DashboardIdSettingsIndexRoute: typeof DashboardIdSettingsIndexRoute
 }
@@ -1157,7 +1213,8 @@ const DashboardIdSettingsRouteChildren: DashboardIdSettingsRouteChildren = {
   DashboardIdSettingsIntegrationsRoute: DashboardIdSettingsIntegrationsRoute,
   DashboardIdSettingsLogsRoute: DashboardIdSettingsLogsRoute,
   DashboardIdSettingsOrganizationRoute: DashboardIdSettingsOrganizationRoute,
-  DashboardIdSettingsProvidersRoute: DashboardIdSettingsProvidersRoute,
+  DashboardIdSettingsProvidersRoute:
+    DashboardIdSettingsProvidersRouteWithChildren,
   DashboardIdSettingsTeamsRoute: DashboardIdSettingsTeamsRoute,
   DashboardIdSettingsIndexRoute: DashboardIdSettingsIndexRoute,
 }
diff --git a/services/platform/app/routes/dashboard/$id/settings/providers.tsx b/services/platform/app/routes/dashboard/$id/settings/providers.tsx
index 44926b7e5d..33eefb0132 100644
--- a/services/platform/app/routes/dashboard/$id/settings/providers.tsx
+++ b/services/platform/app/routes/dashboard/$id/settings/providers.tsx
@@ -1,7 +1,6 @@
-import { createFileRoute } from '@tanstack/react-router';
+import { Outlet, createFileRoute } from '@tanstack/react-router';
 
 import { AccessDenied } from '@/app/components/layout/access-denied';
-import { ProvidersPage } from '@/app/features/settings/providers/components/providers-page';
 import { useAbility } from '@/app/hooks/use-ability';
 import { useT } from '@/lib/i18n/client';
 import { seo } from '@/lib/utils/seo';
@@ -10,18 +9,16 @@ export const Route = createFileRoute('/dashboard/$id/settings/providers')({
   head: () => ({
     meta: seo('providers'),
   }),
-  component: ProvidersRoute,
+  component: ProvidersLayout,
 });
 
-function ProvidersRoute() {
-  const { id: organizationId } = Route.useParams();
+function ProvidersLayout() {
   const { t } = useT('accessDenied');
-
   const ability = useAbility();
 
   if (ability.cannot('read', 'developerSettings')) {
     return <AccessDenied message={t('integrations')} />;
   }
 
-  return <ProvidersPage organizationId={organizationId} />;
+  return <Outlet />;
 }
diff --git a/services/platform/app/routes/dashboard/$id/settings/providers/$providerName.tsx b/services/platform/app/routes/dashboard/$id/settings/providers/$providerName.tsx
new file mode 100644
index 0000000000..86d8bbac8e
--- /dev/null
+++ b/services/platform/app/routes/dashboard/$id/settings/providers/$providerName.tsx
@@ -0,0 +1,403 @@
+import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
+import { ChevronRight, Plus, Trash2 } from 'lucide-react';
+import { useCallback, useState } from 'react';
+
+import { Badge } from '@/app/components/ui/feedback/badge';
+import { Skeleton } from '@/app/components/ui/feedback/skeleton';
+import { Checkbox } from '@/app/components/ui/forms/checkbox';
+import { Input } from '@/app/components/ui/forms/input';
+import { Textarea } from '@/app/components/ui/forms/textarea';
+import { Card } from '@/app/components/ui/layout/card';
+import { Grid, HStack, Stack } from '@/app/components/ui/layout/layout';
+import { Button } from '@/app/components/ui/primitives/button';
+import { Text } from '@/app/components/ui/typography/text';
+import {
+  useDeleteProvider,
+  useSaveProvider,
+  useSaveProviderSecret,
+} from '@/app/features/settings/providers/hooks/mutations';
+import {
+  useHasProviderSecret,
+  useReadProvider,
+} from '@/app/features/settings/providers/hooks/queries';
+import {
+  ProviderConfigProvider,
+  useProviderConfig,
+} from '@/app/features/settings/providers/hooks/use-provider-config-context';
+import { useT } from '@/lib/i18n/client';
+
+export const Route = createFileRoute(
+  '/dashboard/$id/settings/providers/$providerName',
+)({
+  component: ProviderDetailRoute,
+});
+
+function ProviderDetailRoute() {
+  const { id: organizationId, providerName } = Route.useParams();
+  const { data, isLoading } = useReadProvider('default', providerName);
+
+  if (isLoading) {
+    return (
+      <Stack gap={4} className="p-6">
+        <Skeleton className="h-8 w-48" />
+        <Skeleton className="h-64 w-full" />
+      </Stack>
+    );
+  }
+
+  if (!data?.ok) {
+    return (
+      <Stack gap={4} className="p-6">
+        <Text variant="muted">Provider not found: {providerName}</Text>
+        <Link
+          to="/dashboard/$id/settings/providers"
+          params={{ id: organizationId }}
+        >
+          <Button variant="secondary">Back to providers</Button>
+        </Link>
+      </Stack>
+    );
+  }
+
+  return (
+    <ProviderConfigProvider
+      providerName={providerName}
+      initialConfig={data.config}
+    >
+      <ProviderDetailContent
+        organizationId={organizationId}
+        providerName={providerName}
+      />
+    </ProviderConfigProvider>
+  );
+}
+
+function ProviderDetailContent({
+  organizationId,
+  providerName,
+}: {
+  organizationId: string;
+  providerName: string;
+}) {
+  const { t } = useT('settings');
+  const navigate = useNavigate();
+  const { config, isDirty, isSaving, resetConfig, markSaving } =
+    useProviderConfig();
+  const saveProvider = useSaveProvider();
+  const deleteProvider = useDeleteProvider();
+
+  const handleSave = useCallback(async () => {
+    markSaving(true);
+    try {
+      await saveProvider.mutateAsync({
+        orgSlug: 'default',
+        providerName,
+        config,
+      });
+    } finally {
+      markSaving(false);
+    }
+  }, [config, providerName, saveProvider, markSaving]);
+
+  const handleDelete = useCallback(async () => {
+    await deleteProvider.mutateAsync({
+      orgSlug: 'default',
+      providerName,
+    });
+    void navigate({
+      to: '/dashboard/$id/settings/providers',
+      params: { id: organizationId },
+    });
+  }, [providerName, organizationId, deleteProvider, navigate]);
+
+  return (
+    <Stack gap={6} className="p-6">
+      <HStack justify="between" align="center">
+        <HStack gap={2} align="center">
+          <Link
+            to="/dashboard/$id/settings/providers"
+            params={{ id: organizationId }}
+            className="text-muted-foreground hover:text-foreground text-sm"
+          >
+            {t('providers.title')}
+          </Link>
+          <ChevronRight className="text-muted-foreground size-4" />
+          <Text as="span" variant="label">
+            {config.displayName}
+          </Text>
+        </HStack>
+        <HStack gap={2}>
+          {isDirty && (
+            <>
+              <Button variant="secondary" size="sm" onClick={resetConfig}>
+                {t('providers.discard')}
+              </Button>
+              <Button size="sm" onClick={handleSave} disabled={isSaving}>
+                {isSaving ? t('providers.saving') : t('providers.save')}
+              </Button>
+            </>
+          )}
+          <Button variant="destructive" size="sm" onClick={handleDelete}>
+            {t('providers.deleteProvider')}
+          </Button>
+        </HStack>
+      </HStack>
+
+      <GeneralSection />
+      <ApiKeySection providerName={providerName} />
+      <ModelsSection />
+    </Stack>
+  );
+}
+
+function GeneralSection() {
+  const { t } = useT('settings');
+  const { config, updateConfig } = useProviderConfig();
+
+  return (
+    <Card contentClassName="p-5">
+      <Stack gap={4}>
+        <Text as="span" variant="label" className="text-base">
+          {t('providers.general')}
+        </Text>
+        <div>
+          <Text as="label" variant="muted" className="mb-1.5 block text-sm">
+            {t('providers.displayName')}
+          </Text>
+          <Input
+            value={config.displayName}
+            onChange={(e) => updateConfig({ displayName: e.target.value })}
+          />
+        </div>
+        <div>
+          <Text as="label" variant="muted" className="mb-1.5 block text-sm">
+            {t('providers.description')}
+          </Text>
+          <Textarea
+            value={config.description ?? ''}
+            onChange={(e) => updateConfig({ description: e.target.value })}
+            rows={3}
+          />
+        </div>
+        <div>
+          <Text as="label" variant="muted" className="mb-1.5 block text-sm">
+            {t('providers.baseUrl')}
+          </Text>
+          <Input
+            value={config.baseUrl}
+            onChange={(e) => updateConfig({ baseUrl: e.target.value })}
+          />
+        </div>
+      </Stack>
+    </Card>
+  );
+}
+
+function ApiKeySection({ providerName }: { providerName: string }) {
+  const { t } = useT('settings');
+  const { data: hasSecret } = useHasProviderSecret('default', providerName);
+  const saveSecret = useSaveProviderSecret();
+  const [apiKey, setApiKey] = useState('');
+  const [saving, setSaving] = useState(false);
+
+  const handleSaveKey = useCallback(async () => {
+    if (!apiKey.trim()) return;
+    setSaving(true);
+    try {
+      await saveSecret.mutateAsync({
+        orgSlug: 'default',
+        providerName,
+        apiKey: apiKey.trim(),
+      });
+      setApiKey('');
+    } finally {
+      setSaving(false);
+    }
+  }, [apiKey, providerName, saveSecret]);
+
+  return (
+    <Card contentClassName="p-5">
+      <Stack gap={3}>
+        <HStack justify="between" align="center">
+          <Text as="span" variant="label" className="text-base">
+            {t('providers.apiKey')}
+          </Text>
+          {hasSecret ? (
+            <Badge variant="green">{t('providers.apiKeyConfigured')}</Badge>
+          ) : (
+            <Badge variant="outline">
+              {t('providers.apiKeyNotConfigured')}
+            </Badge>
+          )}
+        </HStack>
+        <HStack gap={2}>
+          <Input
+            type="password"
+            placeholder={
+              hasSecret
+                ? t('providers.apiKeyReplace')
+                : t('providers.apiKeyEnter')
+            }
+            value={apiKey}
+            onChange={(e) => setApiKey(e.target.value)}
+            className="max-w-md"
+          />
+          <Button
+            size="sm"
+            onClick={handleSaveKey}
+            disabled={!apiKey.trim() || saving}
+          >
+            {saving ? t('providers.saving') : t('providers.saveKey')}
+          </Button>
+        </HStack>
+      </Stack>
+    </Card>
+  );
+}
+
+function ModelsSection() {
+  const { t } = useT('settings');
+  const { config, updateConfig } = useProviderConfig();
+
+  const addModel = useCallback(() => {
+    const newModel = {
+      id: '',
+      displayName: '',
+      tags: ['chat' as const],
+    };
+    updateConfig({ models: [...config.models, newModel] });
+  }, [config.models, updateConfig]);
+
+  const updateModel = useCallback(
+    (index: number, partial: Record<string, unknown>) => {
+      const updated = config.models.map((m, i) =>
+        i === index ? { ...m, ...partial } : m,
+      );
+      updateConfig({ models: updated });
+    },
+    [config.models, updateConfig],
+  );
+
+  const removeModel = useCallback(
+    (index: number) => {
+      updateConfig({ models: config.models.filter((_, i) => i !== index) });
+    },
+    [config.models, updateConfig],
+  );
+
+  return (
+    <Stack gap={3}>
+      <HStack justify="between" align="center">
+        <Text as="span" variant="label" className="text-base">
+          {t('providers.models')}
+        </Text>
+        <Button variant="secondary" size="sm" onClick={addModel}>
+          <Plus className="mr-1.5 size-4" />
+          {t('providers.addModel')}
+        </Button>
+      </HStack>
+
+      {config.models.map((model, index) => (
+        <Card key={index} contentClassName="p-4">
+          <Stack gap={3}>
+            <HStack justify="between" align="start">
+              <Stack gap={3} className="flex-1">
+                <div>
+                  <Text
+                    as="label"
+                    variant="muted"
+                    className="mb-1 block text-xs"
+                  >
+                    {t('providers.modelId')}
+                  </Text>
+                  <Input
+                    value={model.id}
+                    onChange={(e) => updateModel(index, { id: e.target.value })}
+                    placeholder="provider/model-name"
+                  />
+                </div>
+                <div>
+                  <Text
+                    as="label"
+                    variant="muted"
+                    className="mb-1 block text-xs"
+                  >
+                    {t('providers.displayName')}
+                  </Text>
+                  <Input
+                    value={model.displayName}
+                    onChange={(e) =>
+                      updateModel(index, { displayName: e.target.value })
+                    }
+                  />
+                </div>
+                <div>
+                  <Text
+                    as="label"
+                    variant="muted"
+                    className="mb-1 block text-xs"
+                  >
+                    {t('providers.description')}
+                  </Text>
+                  <Textarea
+                    value={model.description ?? ''}
+                    onChange={(e) =>
+                      updateModel(index, {
+                        description: e.target.value || undefined,
+                      })
+                    }
+                    placeholder={t('providers.modelDescriptionPlaceholder')}
+                    rows={2}
+                  />
+                </div>
+              </Stack>
+              <Button
+                variant="ghost"
+                size="icon"
+                onClick={() => removeModel(index)}
+              >
+                <Trash2 className="size-4" />
+              </Button>
+            </HStack>
+
+            <HStack gap={4} align="center" className="flex-wrap">
+              {(['chat', 'vision', 'embedding'] as const).map((tag) => (
+                <label key={tag} className="flex items-center gap-1.5 text-sm">
+                  <Checkbox
+                    checked={model.tags.includes(tag)}
+                    onCheckedChange={(checked) => {
+                      const tags = checked
+                        ? [...model.tags, tag]
+                        : model.tags.filter((v) => v !== tag);
+                      updateModel(index, { tags });
+                    }}
+                  />
+                  {tag}
+                </label>
+              ))}
+              {model.tags.includes('embedding') && (
+                <div className="flex items-center gap-1.5">
+                  <Text as="span" variant="muted" className="text-xs">
+                    {t('providers.dimensions')}:
+                  </Text>
+                  <Input
+                    type="number"
+                    value={model.dimensions ?? ''}
+                    onChange={(e) =>
+                      updateModel(index, {
+                        dimensions: e.target.value
+                          ? Number(e.target.value)
+                          : undefined,
+                      })
+                    }
+                    className="w-24"
+                  />
+                </div>
+              )}
+            </HStack>
+          </Stack>
+        </Card>
+      ))}
+    </Stack>
+  );
+}
diff --git a/services/platform/app/routes/dashboard/$id/settings/providers/index.tsx b/services/platform/app/routes/dashboard/$id/settings/providers/index.tsx
new file mode 100644
index 0000000000..4d71f81ed7
--- /dev/null
+++ b/services/platform/app/routes/dashboard/$id/settings/providers/index.tsx
@@ -0,0 +1,12 @@
+import { createFileRoute } from '@tanstack/react-router';
+
+import { ProvidersTable } from '@/app/features/settings/providers/components/providers-table';
+
+export const Route = createFileRoute('/dashboard/$id/settings/providers/')({
+  component: ProvidersIndexRoute,
+});
+
+function ProvidersIndexRoute() {
+  const { id } = Route.useParams();
+  return <ProvidersTable organizationId={id} />;
+}
diff --git a/services/platform/messages/en.json b/services/platform/messages/en.json
index 910c7c90b2..302623a74e 100644
--- a/services/platform/messages/en.json
+++ b/services/platform/messages/en.json
@@ -249,6 +249,10 @@
     "vendors": "Vendors"
   },
   "emptyStates": {
+    "providers": {
+      "title": "No providers configured",
+      "description": "Add an LLM provider to get started with AI features"
+    },
     "customers": {
       "title": "No customers yet",
       "description": "Upload your first customer to get started"
@@ -1433,6 +1437,14 @@
       "deleteFailed": "Failed to delete provider",
       "secretSaveFailed": "Failed to save API key",
       "createFailed": "Failed to create provider",
+      "discard": "Discard",
+      "save": "Save",
+      "saving": "Saving...",
+      "saveKey": "Save key",
+      "apiKeyReplace": "Enter new API key to replace",
+      "apiKeyEnter": "Enter API key",
+      "searchProvider": "Search providers...",
+      "description": "Description",
       "created": "Provider created",
       "modelCount": "{count, plural, one {# model} other {# models}}",
       "removeModel": "Remove model",

From 3667434d1603ae6066723bb363dd931f63f27f54 Mon Sep 17 00:00:00 2001
From: larryro <371767072@qq.com>
Date: Fri, 3 Apr 2026 10:10:37 +0800
Subject: [PATCH 03/13] feat(platform): improve provider detail UI with
 dialog-based editing and masked API keys

Replace inline editing with dialog-based forms for general settings, API keys,
and models. Show masked API keys in the UI, use a table layout for models,
and add startsWith nav matching for nested provider routes.
---
 .sops.yaml                                    |   3 +
 examples/providers/openrouter.secrets.json    |  20 +
 .../components/settings-navigation.tsx        |   1 +
 .../components/provider-edit-panel.tsx        |  30 +-
 .../providers/components/providers-table.tsx  |   9 +-
 .../$id/settings/providers/$providerName.tsx  | 663 ++++++++++++------
 .../platform/convex/providers/file_actions.ts |  25 +-
 services/platform/messages/en.json            |  15 +-
 8 files changed, 540 insertions(+), 226 deletions(-)
 create mode 100644 .sops.yaml
 create mode 100644 examples/providers/openrouter.secrets.json

diff --git a/.sops.yaml b/.sops.yaml
new file mode 100644
index 0000000000..be3e656ded
--- /dev/null
+++ b/.sops.yaml
@@ -0,0 +1,3 @@
+creation_rules:
+  - path_regex: \.secrets\.json$
+    age: age1xsc5y9x0dref9kd6fwv2356pw2zl5s7gp5v6jam9h4q7mv6fm9aqumvqhj
diff --git a/examples/providers/openrouter.secrets.json b/examples/providers/openrouter.secrets.json
new file mode 100644
index 0000000000..c4d1eed477
--- /dev/null
+++ b/examples/providers/openrouter.secrets.json
@@ -0,0 +1,20 @@
+{
+	"apiKey": "ENC[AES256_GCM,data:KHZXdpUGs+WM6YEBpLnAlKpa7kcV/macP7jQuaXllb7yAHUp4bI2PEYjYkRalbP5gyy/FfRpUpvieJTTizJNieNAUIBFFT1WJA==,iv:DwsX6dlZ8xrgp/EzJdiZesf747dH8IiOb6/iVa1yCwk=,tag:qy95qyFBdGC8WpGah8m3Fg==,type:str]",
+	"sops": {
+		"kms": null,
+		"gcp_kms": null,
+		"azure_kv": null,
+		"hc_vault": null,
+		"age": [
+			{
+				"recipient": "age1xsc5y9x0dref9kd6fwv2356pw2zl5s7gp5v6jam9h4q7mv6fm9aqumvqhj",
+				"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBZMHMyZ1dGeVdDL2FZSVpX\nbDYzM1JtT3doQVBCUFNoQUFwV3o1cWVCS3pFClZlMHNJdER1TDBOK01welMwWFk0\nMUNCUVpSbTFjb2t4TE5RTlFuNFpDdDgKLS0tIEpQL1J5LzZFd2o4UjhqQW53YUh2\nYWMvUmMwMVVjcHJuR09WL2pmNlFnd3MK6glsGfY9oPf2UXgC5FUFD2JrOjXw3pNF\nfdgI8Kus8DMZkOtF7OksUoodwmBlYaeNzQnc3VC5GGgMmyI7QnvaDg==\n-----END AGE ENCRYPTED FILE-----\n"
+			}
+		],
+		"lastmodified": "2026-04-03T01:37:55Z",
+		"mac": "ENC[AES256_GCM,data:RXXHQRNr72jWMV90U9w246d8DLmz1qwua+qwg1j/3ZxRoVRBGRMv5PhQ933ueFejpmVFpaMlSsrIR5fJI0g6x3ky1ykwhDTuvAgUEMSyTK/54EExBBTJITCa2OATFjvWyqKnzQ9dZZvoEz/JMo/dJLYeUBuwUYShZOdxc97axbA=,iv:jXHPJVR01g4puGBI/U4AivaXGHlFV9xe3C0CU7rPWmQ=,tag:EdOyEi+oT3lNcLCxuvBcJQ==,type:str]",
+		"pgp": null,
+		"unencrypted_suffix": "_unencrypted",
+		"version": "3.9.4"
+	}
+}
\ No newline at end of file
diff --git a/services/platform/app/features/settings/components/settings-navigation.tsx b/services/platform/app/features/settings/components/settings-navigation.tsx
index b7d0be20bf..6429cf4805 100644
--- a/services/platform/app/features/settings/components/settings-navigation.tsx
+++ b/services/platform/app/features/settings/components/settings-navigation.tsx
@@ -51,6 +51,7 @@ export function SettingsNavigation({
       label: t('providers'),
       href: `/dashboard/${organizationId}/settings/providers`,
       can: ['read', 'developerSettings'],
+      matchMode: 'startsWith',
     },
     {
       labelKey: 'apiKeys',
diff --git a/services/platform/app/features/settings/providers/components/provider-edit-panel.tsx b/services/platform/app/features/settings/providers/components/provider-edit-panel.tsx
index 8ca315b1f9..05ab6342bd 100644
--- a/services/platform/app/features/settings/providers/components/provider-edit-panel.tsx
+++ b/services/platform/app/features/settings/providers/components/provider-edit-panel.tsx
@@ -73,7 +73,8 @@ export function ProviderEditPanel({
     'default',
     providerName,
   );
-  const { data: hasSecret } = useHasProviderSecret('default', providerName);
+  const { data: maskedKey } = useHasProviderSecret('default', providerName);
+  const hasSecret = maskedKey != null;
   const { mutateAsync: saveProvider, isPending: isSaving } = useSaveProvider();
   const { mutateAsync: deleteProvider, isPending: isDeleting } =
     useDeleteProvider();
@@ -245,21 +246,38 @@ export function ProviderEditPanel({
 
             {/* API Key section */}
             <Stack gap={3}>
-              <SectionHeader title={t('providers.apiKey')} as="h3" size="sm" />
-              <HStack gap={2}>
-                <KeyRound className="text-muted-foreground size-4" />
+              <HStack gap={2} justify="between" align="center">
+                <SectionHeader
+                  title={t('providers.apiKey')}
+                  as="h3"
+                  size="sm"
+                />
                 <Badge variant={hasSecret ? 'green' : 'orange'} dot>
                   {hasSecret
                     ? t('providers.apiKeyConfigured')
                     : t('providers.apiKeyNotConfigured')}
                 </Badge>
               </HStack>
+              {maskedKey && (
+                <HStack gap={2} align="center">
+                  <KeyRound className="text-muted-foreground size-4" />
+                  <Text className="text-muted-foreground font-mono text-sm">
+                    {maskedKey}
+                  </Text>
+                </HStack>
+              )}
               <HStack gap={2} align="end">
                 <Input
-                  type="password"
+                  type={apiKey ? 'password' : 'text'}
                   value={apiKey}
                   onChange={(e) => setApiKey(e.target.value)}
-                  placeholder={t('providers.apiKeyPlaceholder')}
+                  placeholder={
+                    hasSecret
+                      ? t('providers.apiKeyReplacePlaceholder', {
+                          maskedKey: maskedKey ?? '',
+                        })
+                      : t('providers.apiKeyPlaceholder')
+                  }
                   wrapperClassName="flex-1"
                 />
                 <Button
diff --git a/services/platform/app/features/settings/providers/components/providers-table.tsx b/services/platform/app/features/settings/providers/components/providers-table.tsx
index c67b797ead..f9601b22cf 100644
--- a/services/platform/app/features/settings/providers/components/providers-table.tsx
+++ b/services/platform/app/features/settings/providers/components/providers-table.tsx
@@ -57,8 +57,9 @@ export function ProvidersTable({ organizationId }: ProvidersTableProps) {
     void queryClient.invalidateQueries({ queryKey: ['config', 'providers'] });
   }, [queryClient]);
 
-  const { columns, searchPlaceholder, stickyLayout, pageSize } =
-    useProvidersTableConfig({ onDeleted: invalidateProviders });
+  const { columns, stickyLayout, pageSize } = useProvidersTableConfig({
+    onDeleted: invalidateProviders,
+  });
 
   const handleRowClick = useCallback(
     (row: Row<ProviderRow>) => {
@@ -73,10 +74,6 @@ export function ProvidersTable({ organizationId }: ProvidersTableProps) {
   const list = useListPage<ProviderRow>({
     dataSource: { type: 'query', data: isLoading ? undefined : providers },
     pageSize,
-    search: {
-      fields: ['displayName', 'name'],
-      placeholder: searchPlaceholder,
-    },
   });
 
   return (
diff --git a/services/platform/app/routes/dashboard/$id/settings/providers/$providerName.tsx b/services/platform/app/routes/dashboard/$id/settings/providers/$providerName.tsx
index 86d8bbac8e..30ac78f61e 100644
--- a/services/platform/app/routes/dashboard/$id/settings/providers/$providerName.tsx
+++ b/services/platform/app/routes/dashboard/$id/settings/providers/$providerName.tsx
@@ -1,14 +1,23 @@
 import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
-import { ChevronRight, Plus, Trash2 } from 'lucide-react';
+import { ChevronRight, KeyRound, Pencil, Plus, Trash2 } from 'lucide-react';
 import { useCallback, useState } from 'react';
 
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from '@/app/components/ui/data-display/table';
+import { FormDialog } from '@/app/components/ui/dialog/form-dialog';
 import { Badge } from '@/app/components/ui/feedback/badge';
 import { Skeleton } from '@/app/components/ui/feedback/skeleton';
 import { Checkbox } from '@/app/components/ui/forms/checkbox';
 import { Input } from '@/app/components/ui/forms/input';
 import { Textarea } from '@/app/components/ui/forms/textarea';
 import { Card } from '@/app/components/ui/layout/card';
-import { Grid, HStack, Stack } from '@/app/components/ui/layout/layout';
+import { HStack, Stack } from '@/app/components/ui/layout/layout';
 import { Button } from '@/app/components/ui/primitives/button';
 import { Text } from '@/app/components/ui/typography/text';
 import {
@@ -150,254 +159,492 @@ function ProviderDetailContent({
   );
 }
 
+function InfoRow({
+  label,
+  children,
+}: {
+  label: string;
+  children: React.ReactNode;
+}) {
+  return (
+    <HStack gap={4} className="py-2.5">
+      <Text variant="muted" className="w-28 shrink-0 text-sm">
+        {label}
+      </Text>
+      <div className="min-w-0 flex-1">{children}</div>
+    </HStack>
+  );
+}
+
 function GeneralSection() {
   const { t } = useT('settings');
   const { config, updateConfig } = useProviderConfig();
+  const [dialogOpen, setDialogOpen] = useState(false);
+  const [form, setForm] = useState({
+    displayName: '',
+    description: '',
+    baseUrl: '',
+  });
+
+  const openDialog = useCallback(() => {
+    setForm({
+      displayName: config.displayName,
+      description: config.description ?? '',
+      baseUrl: config.baseUrl,
+    });
+    setDialogOpen(true);
+  }, [config]);
+
+  const handleSubmit = useCallback(
+    (e: React.FormEvent) => {
+      e.preventDefault();
+      updateConfig({
+        displayName: form.displayName,
+        description: form.description || undefined,
+        baseUrl: form.baseUrl,
+      });
+      setDialogOpen(false);
+    },
+    [form, updateConfig],
+  );
 
   return (
-    <Card contentClassName="p-5">
-      <Stack gap={4}>
-        <Text as="span" variant="label" className="text-base">
-          {t('providers.general')}
-        </Text>
-        <div>
-          <Text as="label" variant="muted" className="mb-1.5 block text-sm">
-            {t('providers.displayName')}
+    <>
+      <Card contentClassName="px-5 py-2">
+        <HStack justify="between" align="center" className="border-b py-2.5">
+          <Text variant="muted" className="text-sm">
+            {t('providers.general')}
           </Text>
+          <Button variant="ghost" size="sm" onClick={openDialog}>
+            <Pencil className="mr-1.5 size-3.5" />
+            {t('providers.editGeneral')}
+          </Button>
+        </HStack>
+        <Stack className="divide-y">
+          <InfoRow label={t('providers.displayName')}>
+            <Text className="text-sm">{config.displayName}</Text>
+          </InfoRow>
+          <InfoRow label={t('providers.description')}>
+            <Text className="text-muted-foreground text-sm">
+              {config.description || '—'}
+            </Text>
+          </InfoRow>
+          <InfoRow label={t('providers.baseUrl')}>
+            <Text className="font-mono text-sm">{config.baseUrl}</Text>
+          </InfoRow>
+        </Stack>
+      </Card>
+
+      <FormDialog
+        open={dialogOpen}
+        onOpenChange={setDialogOpen}
+        title={t('providers.editGeneral')}
+        onSubmit={handleSubmit}
+        isDirty={
+          form.displayName !== config.displayName ||
+          form.description !== (config.description ?? '') ||
+          form.baseUrl !== config.baseUrl
+        }
+      >
+        <Stack gap={4}>
           <Input
-            value={config.displayName}
-            onChange={(e) => updateConfig({ displayName: e.target.value })}
+            label={t('providers.displayName')}
+            value={form.displayName}
+            onChange={(e) =>
+              setForm((f) => ({ ...f, displayName: e.target.value }))
+            }
+            placeholder={t('providers.displayNamePlaceholder')}
+            autoFocus
           />
-        </div>
-        <div>
-          <Text as="label" variant="muted" className="mb-1.5 block text-sm">
-            {t('providers.description')}
-          </Text>
           <Textarea
-            value={config.description ?? ''}
-            onChange={(e) => updateConfig({ description: e.target.value })}
+            label={t('providers.description')}
+            value={form.description}
+            onChange={(e) =>
+              setForm((f) => ({ ...f, description: e.target.value }))
+            }
+            placeholder={t('providers.descriptionPlaceholder')}
             rows={3}
           />
-        </div>
-        <div>
-          <Text as="label" variant="muted" className="mb-1.5 block text-sm">
-            {t('providers.baseUrl')}
-          </Text>
           <Input
-            value={config.baseUrl}
-            onChange={(e) => updateConfig({ baseUrl: e.target.value })}
+            label={t('providers.baseUrl')}
+            value={form.baseUrl}
+            onChange={(e) =>
+              setForm((f) => ({ ...f, baseUrl: e.target.value }))
+            }
+            placeholder={t('providers.baseUrlPlaceholder')}
           />
-        </div>
-      </Stack>
-    </Card>
+        </Stack>
+      </FormDialog>
+    </>
   );
 }
 
 function ApiKeySection({ providerName }: { providerName: string }) {
   const { t } = useT('settings');
-  const { data: hasSecret } = useHasProviderSecret('default', providerName);
+  const { data: maskedKey } = useHasProviderSecret('default', providerName);
+  const hasSecret = maskedKey != null;
   const saveSecret = useSaveProviderSecret();
+  const [dialogOpen, setDialogOpen] = useState(false);
   const [apiKey, setApiKey] = useState('');
   const [saving, setSaving] = useState(false);
 
-  const handleSaveKey = useCallback(async () => {
-    if (!apiKey.trim()) return;
-    setSaving(true);
-    try {
-      await saveSecret.mutateAsync({
-        orgSlug: 'default',
-        providerName,
-        apiKey: apiKey.trim(),
-      });
-      setApiKey('');
-    } finally {
-      setSaving(false);
-    }
-  }, [apiKey, providerName, saveSecret]);
+  const handleSaveKey = useCallback(
+    async (e: React.FormEvent) => {
+      e.preventDefault();
+      if (!apiKey.trim()) return;
+      setSaving(true);
+      try {
+        await saveSecret.mutateAsync({
+          orgSlug: 'default',
+          providerName,
+          apiKey: apiKey.trim(),
+        });
+        setApiKey('');
+        setDialogOpen(false);
+      } finally {
+        setSaving(false);
+      }
+    },
+    [apiKey, providerName, saveSecret],
+  );
 
   return (
-    <Card contentClassName="p-5">
-      <Stack gap={3}>
-        <HStack justify="between" align="center">
-          <Text as="span" variant="label" className="text-base">
-            {t('providers.apiKey')}
-          </Text>
-          {hasSecret ? (
-            <Badge variant="green">{t('providers.apiKeyConfigured')}</Badge>
-          ) : (
-            <Badge variant="outline">
-              {t('providers.apiKeyNotConfigured')}
-            </Badge>
-          )}
-        </HStack>
-        <HStack gap={2}>
-          <Input
-            type="password"
-            placeholder={
-              hasSecret
-                ? t('providers.apiKeyReplace')
-                : t('providers.apiKeyEnter')
-            }
-            value={apiKey}
-            onChange={(e) => setApiKey(e.target.value)}
-            className="max-w-md"
-          />
-          <Button
-            size="sm"
-            onClick={handleSaveKey}
-            disabled={!apiKey.trim() || saving}
-          >
-            {saving ? t('providers.saving') : t('providers.saveKey')}
-          </Button>
-        </HStack>
-      </Stack>
-    </Card>
+    <>
+      <Card contentClassName="px-5 py-2">
+        <InfoRow label={t('providers.apiKey')}>
+          <HStack gap={2} align="center">
+            {hasSecret ? (
+              <>
+                <Badge variant="green" dot>
+                  {t('providers.apiKeyConfigured')}
+                </Badge>
+                <Text className="text-muted-foreground font-mono text-sm">
+                  {maskedKey}
+                </Text>
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  onClick={() => setDialogOpen(true)}
+                >
+                  <Pencil className="mr-1.5 size-3.5" />
+                  {t('providers.editKey')}
+                </Button>
+              </>
+            ) : (
+              <Button
+                variant="secondary"
+                size="sm"
+                onClick={() => setDialogOpen(true)}
+              >
+                <KeyRound className="mr-1.5 size-3.5" />
+                {t('providers.addKey')}
+              </Button>
+            )}
+          </HStack>
+        </InfoRow>
+      </Card>
+
+      <FormDialog
+        open={dialogOpen}
+        onOpenChange={(open) => {
+          setDialogOpen(open);
+          if (!open) setApiKey('');
+        }}
+        title={
+          hasSecret ? t('providers.replaceApiKey') : t('providers.addApiKey')
+        }
+        description={
+          hasSecret
+            ? t('providers.replaceApiKeyDescription', {
+                maskedKey: maskedKey ?? '',
+              })
+            : undefined
+        }
+        onSubmit={handleSaveKey}
+        isSubmitting={saving}
+        isDirty={apiKey.trim().length > 0}
+        submitText={t('providers.saveKey')}
+        submittingText={t('providers.saving')}
+      >
+        <Input
+          type="password"
+          placeholder={t('providers.apiKeyEnter')}
+          value={apiKey}
+          onChange={(e) => setApiKey(e.target.value)}
+          autoFocus
+        />
+      </FormDialog>
+    </>
   );
 }
 
+interface ModelFormState {
+  id: string;
+  displayName: string;
+  description: string;
+  tags: string[];
+  isDefault: boolean;
+  dimensions: string;
+}
+
+const EMPTY_MODEL_FORM: ModelFormState = {
+  id: '',
+  displayName: '',
+  description: '',
+  tags: ['chat'],
+  isDefault: false,
+  dimensions: '',
+};
+
 function ModelsSection() {
   const { t } = useT('settings');
   const { config, updateConfig } = useProviderConfig();
+  const [dialogOpen, setDialogOpen] = useState(false);
+  const [editingIndex, setEditingIndex] = useState<number | null>(null);
+  const [form, setForm] = useState<ModelFormState>(EMPTY_MODEL_FORM);
+  const [deleteIndex, setDeleteIndex] = useState<number | null>(null);
 
-  const addModel = useCallback(() => {
-    const newModel = {
-      id: '',
-      displayName: '',
-      tags: ['chat' as const],
-    };
-    updateConfig({ models: [...config.models, newModel] });
-  }, [config.models, updateConfig]);
-
-  const updateModel = useCallback(
-    (index: number, partial: Record<string, unknown>) => {
-      const updated = config.models.map((m, i) =>
-        i === index ? { ...m, ...partial } : m,
-      );
-      updateConfig({ models: updated });
+  const openAddDialog = useCallback(() => {
+    setEditingIndex(null);
+    setForm(EMPTY_MODEL_FORM);
+    setDialogOpen(true);
+  }, []);
+
+  const openEditDialog = useCallback(
+    (index: number) => {
+      const model = config.models[index];
+      if (!model) return;
+      setEditingIndex(index);
+      setForm({
+        id: model.id,
+        displayName: model.displayName,
+        description: model.description ?? '',
+        tags: [...model.tags],
+        isDefault: model.default ?? false,
+        dimensions: model.dimensions != null ? String(model.dimensions) : '',
+      });
+      setDialogOpen(true);
     },
-    [config.models, updateConfig],
+    [config.models],
   );
 
-  const removeModel = useCallback(
-    (index: number) => {
-      updateConfig({ models: config.models.filter((_, i) => i !== index) });
+  const handleSubmitModel = useCallback(
+    (e: React.FormEvent) => {
+      e.preventDefault();
+      const model = {
+        id: form.id,
+        displayName: form.displayName,
+        description: form.description || undefined,
+        // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- tags are constrained to checkbox values
+        tags: form.tags as Array<'chat' | 'vision' | 'embedding'>,
+        default: form.isDefault || undefined,
+        dimensions: form.dimensions ? Number(form.dimensions) : undefined,
+      };
+      if (editingIndex != null) {
+        const updated = config.models.map((m, i) =>
+          i === editingIndex ? model : m,
+        );
+        updateConfig({ models: updated });
+      } else {
+        updateConfig({ models: [...config.models, model] });
+      }
+      setDialogOpen(false);
     },
-    [config.models, updateConfig],
+    [form, editingIndex, config.models, updateConfig],
   );
 
+  const handleDeleteModel = useCallback(() => {
+    if (deleteIndex == null) return;
+    updateConfig({ models: config.models.filter((_, i) => i !== deleteIndex) });
+    setDeleteIndex(null);
+  }, [deleteIndex, config.models, updateConfig]);
+
   return (
-    <Stack gap={3}>
-      <HStack justify="between" align="center">
-        <Text as="span" variant="label" className="text-base">
-          {t('providers.models')}
-        </Text>
-        <Button variant="secondary" size="sm" onClick={addModel}>
-          <Plus className="mr-1.5 size-4" />
-          {t('providers.addModel')}
-        </Button>
-      </HStack>
+    <>
+      <Stack gap={3}>
+        <HStack justify="between" align="center">
+          <Text as="span" variant="label" className="text-base">
+            {t('providers.models')}
+          </Text>
+          <Button variant="secondary" size="sm" onClick={openAddDialog}>
+            <Plus className="mr-1.5 size-4" />
+            {t('providers.addModel')}
+          </Button>
+        </HStack>
 
-      {config.models.map((model, index) => (
-        <Card key={index} contentClassName="p-4">
-          <Stack gap={3}>
-            <HStack justify="between" align="start">
-              <Stack gap={3} className="flex-1">
-                <div>
-                  <Text
-                    as="label"
-                    variant="muted"
-                    className="mb-1 block text-xs"
-                  >
-                    {t('providers.modelId')}
-                  </Text>
-                  <Input
-                    value={model.id}
-                    onChange={(e) => updateModel(index, { id: e.target.value })}
-                    placeholder="provider/model-name"
-                  />
-                </div>
-                <div>
-                  <Text
-                    as="label"
-                    variant="muted"
-                    className="mb-1 block text-xs"
-                  >
-                    {t('providers.displayName')}
-                  </Text>
-                  <Input
-                    value={model.displayName}
-                    onChange={(e) =>
-                      updateModel(index, { displayName: e.target.value })
-                    }
-                  />
-                </div>
-                <div>
+        <Table>
+          <TableHeader>
+            <TableRow>
+              <TableHead>{t('providers.modelId')}</TableHead>
+              <TableHead>{t('providers.displayName')}</TableHead>
+              <TableHead>{t('providers.description')}</TableHead>
+              <TableHead>{t('providers.tags')}</TableHead>
+              <TableHead className="w-10" />
+            </TableRow>
+          </TableHeader>
+          <TableBody>
+            {config.models.map((model, index) => (
+              <TableRow
+                key={index}
+                className="cursor-pointer"
+                onClick={() => openEditDialog(index)}
+              >
+                <TableCell>
+                  <Text className="font-mono text-sm">{model.id}</Text>
+                </TableCell>
+                <TableCell>
+                  <HStack gap={2} align="center">
+                    <Text className="text-sm">{model.displayName}</Text>
+                    {model.default && (
+                      <Badge variant="blue" className="text-xs">
+                        {t('providers.default')}
+                      </Badge>
+                    )}
+                  </HStack>
+                </TableCell>
+                <TableCell className="max-w-48">
                   <Text
-                    as="label"
-                    variant="muted"
-                    className="mb-1 block text-xs"
+                    className="text-muted-foreground truncate text-sm"
+                    title={model.description ?? ''}
                   >
-                    {t('providers.description')}
+                    {model.description || '—'}
                   </Text>
-                  <Textarea
-                    value={model.description ?? ''}
-                    onChange={(e) =>
-                      updateModel(index, {
-                        description: e.target.value || undefined,
-                      })
-                    }
-                    placeholder={t('providers.modelDescriptionPlaceholder')}
-                    rows={2}
-                  />
-                </div>
-              </Stack>
-              <Button
-                variant="ghost"
-                size="icon"
-                onClick={() => removeModel(index)}
-              >
-                <Trash2 className="size-4" />
-              </Button>
-            </HStack>
-
-            <HStack gap={4} align="center" className="flex-wrap">
-              {(['chat', 'vision', 'embedding'] as const).map((tag) => (
-                <label key={tag} className="flex items-center gap-1.5 text-sm">
-                  <Checkbox
-                    checked={model.tags.includes(tag)}
-                    onCheckedChange={(checked) => {
-                      const tags = checked
-                        ? [...model.tags, tag]
-                        : model.tags.filter((v) => v !== tag);
-                      updateModel(index, { tags });
+                </TableCell>
+                <TableCell>
+                  <HStack gap={1}>
+                    {model.tags.map((tag) => (
+                      <Badge key={tag} variant="outline" className="text-xs">
+                        {tag}
+                      </Badge>
+                    ))}
+                  </HStack>
+                </TableCell>
+                <TableCell>
+                  <Button
+                    variant="ghost"
+                    size="icon"
+                    onClick={(e) => {
+                      e.stopPropagation();
+                      setDeleteIndex(index);
                     }}
-                  />
-                  {tag}
-                </label>
-              ))}
-              {model.tags.includes('embedding') && (
-                <div className="flex items-center gap-1.5">
-                  <Text as="span" variant="muted" className="text-xs">
-                    {t('providers.dimensions')}:
-                  </Text>
-                  <Input
-                    type="number"
-                    value={model.dimensions ?? ''}
-                    onChange={(e) =>
-                      updateModel(index, {
-                        dimensions: e.target.value
-                          ? Number(e.target.value)
-                          : undefined,
-                      })
-                    }
-                    className="w-24"
-                  />
-                </div>
-              )}
-            </HStack>
-          </Stack>
-        </Card>
-      ))}
-    </Stack>
+                  >
+                    <Trash2 className="size-3.5" />
+                  </Button>
+                </TableCell>
+              </TableRow>
+            ))}
+          </TableBody>
+        </Table>
+      </Stack>
+
+      {/* Add / Edit model dialog */}
+      <FormDialog
+        open={dialogOpen}
+        onOpenChange={(open) => {
+          setDialogOpen(open);
+          if (!open) setForm(EMPTY_MODEL_FORM);
+        }}
+        title={
+          editingIndex != null
+            ? t('providers.editModel')
+            : t('providers.addModel')
+        }
+        onSubmit={handleSubmitModel}
+        isDirty={
+          form.id.trim().length > 0 && form.displayName.trim().length > 0
+        }
+        submitText={
+          editingIndex != null ? t('providers.save') : t('providers.addModel')
+        }
+      >
+        <Stack gap={4}>
+          <Input
+            label={t('providers.modelId')}
+            value={form.id}
+            onChange={(e) => setForm((f) => ({ ...f, id: e.target.value }))}
+            placeholder={t('providers.modelIdPlaceholder')}
+            autoFocus
+          />
+          <Input
+            label={t('providers.displayName')}
+            value={form.displayName}
+            onChange={(e) =>
+              setForm((f) => ({ ...f, displayName: e.target.value }))
+            }
+            placeholder={t('providers.modelDisplayNamePlaceholder')}
+          />
+          <Textarea
+            label={t('providers.description')}
+            value={form.description}
+            onChange={(e) =>
+              setForm((f) => ({ ...f, description: e.target.value }))
+            }
+            placeholder={t('providers.modelDescriptionPlaceholder')}
+            rows={2}
+          />
+          <HStack gap={4} align="center" className="flex-wrap">
+            {(['chat', 'vision', 'embedding'] as const).map((tag) => (
+              <label key={tag} className="flex items-center gap-1.5 text-sm">
+                <Checkbox
+                  checked={form.tags.includes(tag)}
+                  onCheckedChange={(checked) => {
+                    setForm((f) => ({
+                      ...f,
+                      tags: checked
+                        ? [...f.tags, tag]
+                        : f.tags.filter((v) => v !== tag),
+                    }));
+                  }}
+                />
+                {tag}
+              </label>
+            ))}
+          </HStack>
+          {form.tags.includes('embedding') && (
+            <Input
+              label={t('providers.dimensions')}
+              type="number"
+              value={form.dimensions}
+              onChange={(e) =>
+                setForm((f) => ({ ...f, dimensions: e.target.value }))
+              }
+              placeholder="e.g., 1536"
+            />
+          )}
+          <label className="flex items-center gap-2 text-sm">
+            <Checkbox
+              checked={form.isDefault}
+              onCheckedChange={(checked) =>
+                setForm((f) => ({ ...f, isDefault: checked === true }))
+              }
+            />
+            {t('providers.defaultModel')}
+          </label>
+        </Stack>
+      </FormDialog>
+
+      {/* Delete confirmation */}
+      <FormDialog
+        open={deleteIndex != null}
+        onOpenChange={(open) => {
+          if (!open) setDeleteIndex(null);
+        }}
+        title={t('providers.deleteModel')}
+        description={
+          deleteIndex != null
+            ? t('providers.deleteModelConfirm', {
+                model: config.models[deleteIndex]?.displayName ?? '',
+              })
+            : undefined
+        }
+        onSubmit={(e) => {
+          e.preventDefault();
+          handleDeleteModel();
+        }}
+        submitText={t('providers.deleteModel')}
+      >
+        <span />
+      </FormDialog>
+    </>
   );
 }
diff --git a/services/platform/convex/providers/file_actions.ts b/services/platform/convex/providers/file_actions.ts
index bda44ac50f..fe7d7bc5ae 100644
--- a/services/platform/convex/providers/file_actions.ts
+++ b/services/platform/convex/providers/file_actions.ts
@@ -38,6 +38,12 @@ import {
 // Internal helpers
 // ---------------------------------------------------------------------------
 
+/** Mask an API key showing the first 6 and last 3 characters. */
+function maskApiKey(key: string): string {
+  if (key.length <= 9) return '••••••••••';
+  return key.slice(0, 6) + '••••••' + key.slice(-3);
+}
+
 async function readProviderFile(
   orgSlug: string,
   providerName: string,
@@ -415,14 +421,15 @@ export const saveProviderSecret = action({
 
 /**
  * Check if a provider has a secrets file configured.
+ * Returns a masked API key (e.g. "sk-or-••••••abc") if configured, or null.
  */
 export const hasProviderSecret = action({
   args: {
     orgSlug: v.string(),
     providerName: v.string(),
   },
-  returns: v.boolean(),
-  handler: async (ctx, args): Promise<boolean> => {
+  returns: v.union(v.string(), v.null()),
+  handler: async (ctx, args): Promise<string | null> => {
     const authUser = await authComponent.getAuthUser(ctx);
     if (!authUser) throw new Error('Unauthenticated');
 
@@ -434,9 +441,19 @@ export const hasProviderSecret = action({
     const { stat: statFile } = await import('node:fs/promises');
     try {
       await statFile(secretsPath);
-      return true;
     } catch {
-      return false;
+      return null;
+    }
+
+    try {
+      const secrets = await decryptSecretsFile(secretsPath);
+      const parsed = parseProviderSecrets(secrets);
+      const key = parsed.apiKey;
+      if (!key) return null;
+      return maskApiKey(key);
+    } catch {
+      // File exists but can't be decrypted — still report as configured
+      return '••••••••••';
     }
   },
 });
diff --git a/services/platform/messages/en.json b/services/platform/messages/en.json
index 302623a74e..a424b8fcfa 100644
--- a/services/platform/messages/en.json
+++ b/services/platform/messages/en.json
@@ -1409,6 +1409,10 @@
       "apiKeyNotConfigured": "API key not configured",
       "models": "Models",
       "addModel": "Add model",
+      "editModel": "Edit model",
+      "deleteModel": "Delete model",
+      "deleteModelConfirm": "Are you sure you want to delete \"{model}\"?",
+      "default": "Default",
       "modelId": "Model ID",
       "displayName": "Display name",
       "description_field": "Description",
@@ -1441,14 +1445,21 @@
       "save": "Save",
       "saving": "Saving...",
       "saveKey": "Save key",
-      "apiKeyReplace": "Enter new API key to replace",
+      "editGeneral": "Edit details",
+      "editKey": "Edit",
+      "addKey": "Add API key",
+      "addApiKey": "Add API Key",
+      "replaceApiKey": "Replace API Key",
+      "replaceApiKeyDescription": "Current key: {maskedKey}",
+      "apiKeyReplace": "Enter new API key to replace ({maskedKey})",
       "apiKeyEnter": "Enter API key",
       "searchProvider": "Search providers...",
       "description": "Description",
       "created": "Provider created",
       "modelCount": "{count, plural, one {# model} other {# models}}",
       "removeModel": "Remove model",
-      "apiKeyPlaceholder": "Enter API key"
+      "apiKeyPlaceholder": "Enter API key",
+      "apiKeyReplacePlaceholder": "Enter new API key to replace ({maskedKey})"
     },
     "form": {
       "name": "Name",

From ae893eb8808a08c09536eedce77b12bc0b1a5d2d Mon Sep 17 00:00:00 2001
From: larryro <371767072@qq.com>
Date: Fri, 3 Apr 2026 10:21:53 +0800
Subject: [PATCH 04/13] feat(cli): integrate SOPS age encryption into CLI
 lifecycle

Add pure-JS age keypair generation (via @noble/curves + @scure/base)
so tale init auto-generates SOPS encryption keys without requiring
external CLI tools. Wire providers/ directory through init, start,
and deploy flows:

- tale init: generate age keypair, scaffold providers/, write .sops.yaml
- tale start: bind-mount providers/ into dev container
- tale deploy: sync providers/ to container, warn on key mismatch
- sops.ts: auto-fallback to shared dev key when SOPS_AGE_KEY unset
- file_actions.ts: gracefully skip providers with undecryptable secrets
---
 .env.example                                  |  6 +--
 bun.lock                                      | 10 +++-
 services/platform/convex/lib/sops.ts          |  9 ++++
 .../platform/convex/providers/file_actions.ts | 12 ++---
 tools/cli/package.json                        |  2 +
 tools/cli/src/commands/deploy/index.ts        |  2 +-
 tools/cli/src/lib/actions/deploy.ts           | 42 ++++++++++++++-
 tools/cli/src/lib/actions/init.ts             | 30 ++++++++++-
 tools/cli/src/lib/actions/start.ts            |  2 +-
 .../generators/generate-dev-compose.ts        |  1 +
 tools/cli/src/lib/config/ensure-config.ts     |  2 +-
 tools/cli/src/lib/config/ensure-env.ts        | 32 +++++++++---
 tools/cli/src/lib/crypto/age-keygen.ts        | 52 +++++++++++++++++++
 13 files changed, 177 insertions(+), 25 deletions(-)
 create mode 100644 tools/cli/src/lib/crypto/age-keygen.ts

diff --git a/.env.example b/.env.example
index 5e2067dc66..bcdcbaad44 100644
--- a/.env.example
+++ b/.env.example
@@ -138,9 +138,9 @@ INSTANCE_SECRET=0516d5cddc8b9bbc01238b8696f13c711983f45f6dc4dbf9dc66ba42fc16f504
 # OPTIONAL: Provider Secrets Encryption
 # ============================================================================
 # Age secret key used by SOPS to decrypt provider secret files (*.secrets.json).
-# Generate a keypair with: age-keygen
-# Only needed if you use file-based provider config with encrypted secrets.
-# SOPS_AGE_KEY=
+# Auto-generated by tale init. Auto-applied in dev mode if unset.
+# Dev default:
+# SOPS_AGE_KEY=AGE-SECRET-KEY-17FH890V3A765GWGYA3E83UEFZNRZL3T4GJ0Q7WSS5GRGEDE9G83SCV3UTH
 
 # ============================================================================
 # OPTIONAL: Operator Service (Browser Automation)
diff --git a/bun.lock b/bun.lock
index 7bde585277..a70806fbb5 100644
--- a/bun.lock
+++ b/bun.lock
@@ -188,6 +188,8 @@
       "version": "1.0.0-dev",
       "dependencies": {
         "@inquirer/prompts": "^8.0.0",
+        "@noble/curves": "^1.8.0",
+        "@scure/base": "^1.2.0",
         "commander": "^14.0.0",
         "yaml": "^2.7.0",
       },
@@ -658,7 +660,9 @@
 
     "@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="],
 
-    "@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
+    "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="],
+
+    "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
 
     "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
 
@@ -1078,6 +1082,8 @@
 
     "@scarf/scarf": ["@scarf/scarf@1.4.0", "", {}, "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ=="],
 
+    "@scure/base": ["@scure/base@1.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="],
+
     "@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@10.40.0", "", { "dependencies": { "@sentry/core": "10.40.0" } }, "sha512-3CDeVNBXYOIvBVdT0SOdMZx5LzYDLuhGK/z7A14sYZz4Cd2+f4mSeFDaEOoH/g2SaY2CKR5KGkAADy8IyjZ21w=="],
 
     "@sentry-internal/feedback": ["@sentry-internal/feedback@10.40.0", "", { "dependencies": { "@sentry/core": "10.40.0" } }, "sha512-V/ixkcdCNMo04KgsCEeNEu966xUUTD6czKT2LOAO5siZACqFjT/Rp9VR1n7QQrVo3sL7P3QNiTHtX0jaeWbwzg=="],
@@ -3212,6 +3218,8 @@
 
     "argparse/sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
 
+    "better-auth/@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
+
     "better-auth/jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
 
     "bl/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
diff --git a/services/platform/convex/lib/sops.ts b/services/platform/convex/lib/sops.ts
index ab2b33e18a..d1a1fc4c8a 100644
--- a/services/platform/convex/lib/sops.ts
+++ b/services/platform/convex/lib/sops.ts
@@ -10,6 +10,10 @@
 import { execSync } from 'node:child_process';
 import { stat } from 'node:fs/promises';
 
+/** Dev-only fallback key — matches the public key in the repo's .sops.yaml. */
+const DEV_AGE_KEY =
+  'AGE-SECRET-KEY-17FH890V3A765GWGYA3E83UEFZNRZL3T4GJ0Q7WSS5GRGEDE9G83SCV3UTH';
+
 interface CacheEntry {
   data: Record<string, unknown>;
   mtimeMs: number;
@@ -26,6 +30,11 @@ export async function decryptSecretsFile(
     return cached.data;
   }
 
+  // Auto-set dev fallback key if no key is configured
+  if (!process.env.SOPS_AGE_KEY && !process.env.SOPS_AGE_KEY_FILE) {
+    process.env.SOPS_AGE_KEY = DEV_AGE_KEY;
+  }
+
   let stdout: string;
   try {
     stdout = execSync(`sops -d --output-type json "${filePath}"`, {
diff --git a/services/platform/convex/providers/file_actions.ts b/services/platform/convex/providers/file_actions.ts
index fe7d7bc5ae..1b149169e8 100644
--- a/services/platform/convex/providers/file_actions.ts
+++ b/services/platform/convex/providers/file_actions.ts
@@ -109,20 +109,16 @@ async function loadAllProviders(
     try {
       const raw = await decryptSecretsFile(secretsPath);
       secrets = parseProviderSecrets(raw);
-    } catch (err) {
-      throw new Error(
-        `Failed to load secrets for provider "${providerName}": ${err instanceof Error ? err.message : String(err)}`,
-        { cause: err },
+    } catch {
+      console.warn(
+        `Provider "${providerName}": secrets not available, skipping. Configure the API key in the management UI.`,
       );
+      continue;
     }
 
     providers.push({ name: providerName, config: result.data, secrets });
   }
 
-  if (providers.length === 0) {
-    throw new Error('No valid providers could be loaded.');
-  }
-
   return providers;
 }
 
diff --git a/tools/cli/package.json b/tools/cli/package.json
index 94ad5dde1b..309771c868 100644
--- a/tools/cli/package.json
+++ b/tools/cli/package.json
@@ -17,6 +17,8 @@
   },
   "dependencies": {
     "@inquirer/prompts": "^8.0.0",
+    "@noble/curves": "^1.8.0",
+    "@scure/base": "^1.2.0",
     "commander": "^14.0.0",
     "yaml": "^2.7.0"
   },
diff --git a/tools/cli/src/commands/deploy/index.ts b/tools/cli/src/commands/deploy/index.ts
index da30328e42..4565da6c83 100644
--- a/tools/cli/src/commands/deploy/index.ts
+++ b/tools/cli/src/commands/deploy/index.ts
@@ -30,7 +30,7 @@ export function createDeployCommand(): Command {
     .action(async (versionArg: string | undefined, options) => {
       try {
         const deployDir = await ensureConfig();
-        const envSetupSuccess = await ensureEnv({ deployDir });
+        const { success: envSetupSuccess } = await ensureEnv({ deployDir });
         if (!envSetupSuccess) {
           process.exit(1);
         }
diff --git a/tools/cli/src/lib/actions/deploy.ts b/tools/cli/src/lib/actions/deploy.ts
index 14afc8569c..c0d2d270a0 100644
--- a/tools/cli/src/lib/actions/deploy.ts
+++ b/tools/cli/src/lib/actions/deploy.ts
@@ -1,5 +1,6 @@
-import { existsSync } from 'node:fs';
+import { existsSync, readFileSync } from 'node:fs';
 import { join } from 'node:path';
+import { parse as parseYaml } from 'yaml';
 
 import { PROJECT_NAME, type DeploymentEnv } from '../../utils/load-env';
 import * as logger from '../../utils/logger';
@@ -14,6 +15,7 @@ import {
   isRotatableService,
   isStatefulService,
 } from '../compose/types';
+import { deriveAgePublicKey } from '../crypto/age-keygen';
 import { dockerCompose } from '../docker/docker-compose';
 import { ensureNetwork } from '../docker/ensure-network';
 import { ensureVolumes } from '../docker/ensure-volumes';
@@ -417,7 +419,13 @@ export async function deploy(options: DeployOptions): Promise<void> {
   });
 }
 
-const SYNC_DIRS = ['agents', 'workflows', 'integrations', 'branding'];
+const SYNC_DIRS = [
+  'agents',
+  'workflows',
+  'integrations',
+  'branding',
+  'providers',
+];
 
 async function syncProjectFiles(
   containerName: string,
@@ -437,6 +445,9 @@ async function syncProjectFiles(
     return;
   }
 
+  // Check SOPS key consistency before syncing
+  checkSopsKeyConsistency(projectDir);
+
   logger.blank();
   logger.step(`${prefix}Syncing project files to ${containerName}...`);
 
@@ -468,3 +479,30 @@ async function syncProjectFiles(
     logger.success('Project files synced');
   }
 }
+
+function checkSopsKeyConsistency(projectDir: string): void {
+  const sopsAgeKey = process.env.SOPS_AGE_KEY;
+  const sopsYamlPath = join(projectDir, 'providers', '.sops.yaml');
+
+  if (!sopsAgeKey || !existsSync(sopsYamlPath)) return;
+
+  try {
+    const sopsYaml = parseYaml(readFileSync(sopsYamlPath, 'utf-8'));
+    const configuredPublicKey = sopsYaml?.creation_rules?.[0]?.age as
+      | string
+      | undefined;
+    if (!configuredPublicKey) return;
+
+    const derivedPublicKey = deriveAgePublicKey(sopsAgeKey);
+    if (derivedPublicKey !== configuredPublicKey) {
+      logger.warn(
+        'SOPS_AGE_KEY in .env does not match the public key in providers/.sops.yaml.',
+      );
+      logger.warn(
+        'Encrypted provider secrets may not decrypt. Reconfigure API keys via the management UI if needed.',
+      );
+    }
+  } catch {
+    // Non-critical check — don't block deployment
+  }
+}
diff --git a/tools/cli/src/lib/actions/init.ts b/tools/cli/src/lib/actions/init.ts
index 913015a82e..4c8a8a8d05 100644
--- a/tools/cli/src/lib/actions/init.ts
+++ b/tools/cli/src/lib/actions/init.ts
@@ -1,6 +1,7 @@
 import { existsSync } from 'node:fs';
 import { mkdir, readFile, writeFile } from 'node:fs/promises';
 import { dirname, join, resolve } from 'node:path';
+import { stringify } from 'yaml';
 
 import pkg from '../../../package.json';
 import * as logger from '../../utils/logger';
@@ -122,11 +123,34 @@ export async function init(options: InitOptions): Promise<void> {
   // Ensure .gitignore
   await ensureGitignore(target);
 
+  // Copy provider configs (public JSON only, not encrypted secrets)
+  logger.step('Copying provider configurations...');
+  const providerFiles = getEmbeddedExamples('providers');
+  const providerConfigFiles = new Map<string, string>();
+  for (const [relPath, content] of providerFiles) {
+    if (!relPath.endsWith('.secrets.json')) {
+      providerConfigFiles.set(relPath, content);
+    }
+  }
+  await writeEmbeddedFiles(providerConfigFiles, join(target, 'providers'));
+
   // .env setup
+  let agePublicKey: string | undefined;
   if (!options.noEnv) {
     const { ensureEnv } = await import('../config/ensure-env');
     logger.blank();
-    await ensureEnv({ deployDir: target, skipIfExists: true });
+    const result = await ensureEnv({ deployDir: target, skipIfExists: true });
+    agePublicKey = result.agePublicKey;
+  }
+
+  // Write .sops.yaml if age keypair was generated
+  if (agePublicKey) {
+    logger.step('Writing SOPS encryption config...');
+    const sopsConfig = stringify({
+      creation_rules: [{ path_regex: '\\.secrets\\.json$', age: agePublicKey }],
+    });
+    await writeFile(join(target, '.sops.yaml'), sopsConfig);
+    await writeFile(join(target, 'providers', '.sops.yaml'), sopsConfig);
   }
 
   logger.blank();
@@ -138,6 +162,7 @@ export async function init(options: InitOptions): Promise<void> {
     ['Agents', `${agentFiles.size} files`],
     ['Workflows', `${workflowFiles.size} files`],
     ['Integrations', `${integrationFiles.size} files`],
+    ['Providers', `${providerConfigFiles.size} files`],
     ['Branding', '1 file'],
   ]);
   logger.blank();
@@ -151,6 +176,9 @@ export async function init(options: InitOptions): Promise<void> {
   logger.info(
     `  ${step++}. Edit agents/, workflows/, integrations/, and branding/ to customize your setup`,
   );
+  logger.info(
+    `  ${step++}. Configure provider API keys in the management UI after starting`,
+  );
   logger.info(
     `  ${step++}. Open the project in an AI-powered editor (Claude Code, Cursor, Copilot, or Windsurf) for guided config creation`,
   );
diff --git a/tools/cli/src/lib/actions/start.ts b/tools/cli/src/lib/actions/start.ts
index 5b5b7d8301..e8f6e52c05 100644
--- a/tools/cli/src/lib/actions/start.ts
+++ b/tools/cli/src/lib/actions/start.ts
@@ -89,7 +89,7 @@ export async function start(options: StartOptions): Promise<void> {
     logger.warn('No .env file found. Running environment setup...');
     logger.blank();
     const { ensureEnv } = await import('../config/ensure-env');
-    const success = await ensureEnv({ deployDir: projectDir });
+    const { success } = await ensureEnv({ deployDir: projectDir });
     if (!success) {
       throw new Error(
         'Environment setup failed. Cannot start without .env file.',
diff --git a/tools/cli/src/lib/compose/generators/generate-dev-compose.ts b/tools/cli/src/lib/compose/generators/generate-dev-compose.ts
index 4883d8e6db..57fd1f96c1 100644
--- a/tools/cli/src/lib/compose/generators/generate-dev-compose.ts
+++ b/tools/cli/src/lib/compose/generators/generate-dev-compose.ts
@@ -30,6 +30,7 @@ export function generateDevCompose(
     './workflows:/app/data/workflows',
     './integrations:/app/data/integrations',
     './branding:/app/data/branding',
+    './providers:/app/data/providers',
     'caddy-data:/caddy-data:ro',
   ];
   platform.environment = {
diff --git a/tools/cli/src/lib/config/ensure-config.ts b/tools/cli/src/lib/config/ensure-config.ts
index 4d9643c964..ab452068d4 100644
--- a/tools/cli/src/lib/config/ensure-config.ts
+++ b/tools/cli/src/lib/config/ensure-config.ts
@@ -111,7 +111,7 @@ async function runFirstRunSetup(): Promise<string> {
   logger.info(`Deployment directory: ${deployDir}`);
   logger.info(`Config file: ${getConfigFilePath()}`);
 
-  const envSetupSuccess = await ensureEnv({ deployDir });
+  const { success: envSetupSuccess } = await ensureEnv({ deployDir });
   if (!envSetupSuccess) {
     process.exit(1);
   }
diff --git a/tools/cli/src/lib/config/ensure-env.ts b/tools/cli/src/lib/config/ensure-env.ts
index 153c075b67..43815f42f7 100644
--- a/tools/cli/src/lib/config/ensure-env.ts
+++ b/tools/cli/src/lib/config/ensure-env.ts
@@ -5,6 +5,7 @@ import { writeFile } from 'node:fs/promises';
 import { join } from 'node:path';
 
 import * as logger from '../../utils/logger';
+import { generateAgeKeypair } from '../crypto/age-keygen';
 
 function listTaleVolumes(): string[] {
   try {
@@ -38,15 +39,22 @@ interface EnvSetupOptions {
   skipIfExists?: boolean;
 }
 
-export async function ensureEnv(options: EnvSetupOptions): Promise<boolean> {
+export interface EnvSetupResult {
+  success: boolean;
+  agePublicKey?: string;
+}
+
+export async function ensureEnv(
+  options: EnvSetupOptions,
+): Promise<EnvSetupResult> {
   const envPath = join(options.deployDir, '.env');
 
   if (existsSync(envPath)) {
     if (options.skipIfExists) {
-      return true;
+      return { success: true };
     }
     logger.debug(`Environment file already exists at ${envPath}`);
-    return true;
+    return { success: true };
   }
 
   if (!isTTY) {
@@ -58,13 +66,13 @@ export async function ensureEnv(options: EnvSetupOptions): Promise<boolean> {
     logger.info('or create the .env file manually.');
     logger.blank();
     logger.info('You can copy from .env.example in the project root.');
-    return false;
+    return { success: false };
   }
 
   return await runEnvSetup(envPath);
 }
 
-async function runEnvSetup(envPath: string): Promise<boolean> {
+async function runEnvSetup(envPath: string): Promise<EnvSetupResult> {
   const { input, password, select } = await import('@inquirer/prompts');
 
   const existingVolumes = listTaleVolumes();
@@ -85,7 +93,7 @@ async function runEnvSetup(envPath: string): Promise<boolean> {
         'Cannot continue with existing volumes. Run "tale init" again after removing manually:',
       );
       logger.info('  docker volume rm ' + existingVolumes.join(' '));
-      return false;
+      return { success: false };
     }
     logger.step('Stopping Tale containers...');
     try {
@@ -183,11 +191,15 @@ async function runEnvSetup(envPath: string): Promise<boolean> {
   const dbPassword = generatePassword();
   logger.info('Generated new database password.');
 
+  const ageKeypair = generateAgeKeypair();
+  logger.info('Generated age encryption keypair for provider secrets.');
+
   const secrets = {
     betterAuthSecret: generateBase64Secret(),
     encryptionSecretHex: generateHexSecret(),
     instanceSecret: generateHexSecret(),
     dbPassword,
+    sopsAgeKey: ageKeypair.secretKey,
   };
 
   const envContent = generateEnvContent({
@@ -209,7 +221,7 @@ async function runEnvSetup(envPath: string): Promise<boolean> {
   logger.info('You can modify the .env file later to customize settings.');
   logger.blank();
 
-  return true;
+  return { success: true, agePublicKey: ageKeypair.publicKey };
 }
 
 interface EnvConfig {
@@ -222,6 +234,7 @@ interface EnvConfig {
   encryptionSecretHex: string;
   instanceSecret: string;
   dbPassword: string;
+  sopsAgeKey: string;
 }
 
 function generateEnvContent(config: EnvConfig): string {
@@ -275,6 +288,11 @@ function generateEnvContent(config: EnvConfig): string {
     'OPENAI_VISION_MODEL=qwen/qwen3-vl-32b-instruct',
     'EMBEDDING_DIMENSIONS=1536',
     '',
+    '# ============================================================================',
+    '# Provider Secrets Encryption (age/SOPS)',
+    '# ============================================================================',
+    `SOPS_AGE_KEY=${config.sopsAgeKey}`,
+    '',
   );
 
   return lines.join('\n');
diff --git a/tools/cli/src/lib/crypto/age-keygen.ts b/tools/cli/src/lib/crypto/age-keygen.ts
new file mode 100644
index 0000000000..b63ad4d611
--- /dev/null
+++ b/tools/cli/src/lib/crypto/age-keygen.ts
@@ -0,0 +1,52 @@
+/**
+ * Pure-JS age keypair generation using X25519.
+ *
+ * Generates age-compatible keypairs without requiring the `age` CLI binary.
+ * Uses @noble/curves for X25519 and @scure/base for Bech32 encoding.
+ */
+
+import { x25519 } from '@noble/curves/ed25519';
+import { bech32 } from '@scure/base';
+
+export interface AgeKeypair {
+  secretKey: string; // "AGE-SECRET-KEY-1..."
+  publicKey: string; // "age1..."
+}
+
+const SECRET_HRP = 'age-secret-key-';
+const PUBLIC_HRP = 'age';
+
+/**
+ * Generate a new age X25519 keypair.
+ */
+export function generateAgeKeypair(): AgeKeypair {
+  const secretBytes = crypto.getRandomValues(new Uint8Array(32));
+  const publicBytes = x25519.getPublicKey(secretBytes);
+
+  const secretKey = bech32
+    .encode(SECRET_HRP, bech32.toWords(secretBytes))
+    .toUpperCase();
+  const publicKey = bech32.encode(PUBLIC_HRP, bech32.toWords(publicBytes));
+
+  return { secretKey, publicKey };
+}
+
+/**
+ * Derive the age public key from an existing secret key string.
+ *
+ * @param secretKey - The "AGE-SECRET-KEY-1..." string
+ * @returns The corresponding "age1..." public key
+ */
+export function deriveAgePublicKey(secretKey: string): string {
+  const lowercase = secretKey.toLowerCase();
+  if (!lowercase.includes('1')) {
+    throw new Error('Invalid age secret key: missing bech32 separator');
+  }
+  const decoded = bech32.decode(lowercase as `${string}1${string}`, false);
+  if (decoded.prefix !== SECRET_HRP) {
+    throw new Error(`Invalid age secret key prefix: "${decoded.prefix}"`);
+  }
+  const secretBytes = bech32.fromWords(decoded.words);
+  const publicBytes = x25519.getPublicKey(new Uint8Array(secretBytes));
+  return bech32.encode(PUBLIC_HRP, bech32.toWords(publicBytes));
+}

From eced62987da8438a5e404cba9d6ba5d7372ade5a Mon Sep 17 00:00:00 2001
From: larryro <371767072@qq.com>
Date: Fri, 3 Apr 2026 10:30:13 +0800
Subject: [PATCH 05/13] feat(platform): seed builtin provider configs in Docker
 image

Add provider config files to the Docker build and entrypoint seeding
logic, matching the existing pattern for agents, workflows, and
integrations. Includes .sops.yaml for SOPS encryption config.
---
 examples/providers/.sops.yaml          |  3 +++
 services/platform/Dockerfile           |  7 +++++--
 services/platform/docker-entrypoint.sh | 24 ++++++++++++++++++++++++
 3 files changed, 32 insertions(+), 2 deletions(-)
 create mode 100644 examples/providers/.sops.yaml

diff --git a/examples/providers/.sops.yaml b/examples/providers/.sops.yaml
new file mode 100644
index 0000000000..be3e656ded
--- /dev/null
+++ b/examples/providers/.sops.yaml
@@ -0,0 +1,3 @@
+creation_rules:
+  - path_regex: \.secrets\.json$
+    age: age1xsc5y9x0dref9kd6fwv2356pw2zl5s7gp5v6jam9h4q7mv6fm9aqumvqhj
diff --git a/services/platform/Dockerfile b/services/platform/Dockerfile
index 5e07d2991c..d2dc195ede 100644
--- a/services/platform/Dockerfile
+++ b/services/platform/Dockerfile
@@ -138,8 +138,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
     && groupadd --system --gid 1001 app || true \
     && useradd --system --uid 1001 --gid app app || true \
     && mkdir -p /home/app && chmod 777 /home/app \
-    && mkdir -p /app/data/convex /app/data/agents /app/data/workflows /app/data/integrations /dashboard /app/agents-builtin /app/workflows-builtin /app/integrations-builtin \
-    && chown -R app:app /app/data /dashboard /app/agents-builtin /app/workflows-builtin /app/integrations-builtin
+    && mkdir -p /app/data/convex /app/data/agents /app/data/workflows /app/data/integrations /dashboard /app/agents-builtin /app/workflows-builtin /app/integrations-builtin /app/providers-builtin \
+    && chown -R app:app /app/data /dashboard /app/agents-builtin /app/workflows-builtin /app/integrations-builtin /app/providers-builtin
 
 # Re-declare VERSION arg (ARGs don't persist after FROM)
 ARG VERSION=dev
@@ -196,6 +196,9 @@ COPY --chown=app:app examples/workflows/ /app/workflows-builtin/
 # Copy builtin integration template files (used to seed /app/data/integrations on first run)
 COPY --chown=app:app examples/integrations/ /app/integrations-builtin/
 
+# Copy builtin provider config files (used to seed /app/data/providers on first run)
+COPY --chown=app:app examples/providers/ /app/providers-builtin/
+
 COPY --from=pruner --chown=app:app /app/services/platform/dist ./dist
 COPY --from=pruner --chown=app:app \
      /app/services/platform/server.ts \
diff --git a/services/platform/docker-entrypoint.sh b/services/platform/docker-entrypoint.sh
index 5d0f1cb0d2..7941010281 100644
--- a/services/platform/docker-entrypoint.sh
+++ b/services/platform/docker-entrypoint.sh
@@ -497,6 +497,30 @@ if [ -d "$integrations_builtin_dir" ] && [ "$(ls -A "$integrations_builtin_dir"
   done
 fi
 
+# Seed builtin provider config files — skip providers the user has configured
+providers_dir="${PROVIDERS_DIR:-${data_dir}/providers}"
+providers_builtin_dir="/app/providers-builtin"
+mkdir -p "$providers_dir"
+if [ -d "$providers_builtin_dir" ] && [ "$(ls -A "$providers_builtin_dir" 2>/dev/null)" ]; then
+  for src in "$providers_builtin_dir"/*.json; do
+    [ -f "$src" ] || continue
+    name="$(basename "$src")"
+    dest="$providers_dir/$name"
+    if [ "$FORCE_SEED" = "true" ]; then
+      cp "$src" "$dest"
+      echo "   ✓ Seeded provider $name (forced)"
+    elif [ -f "$dest" ]; then
+      echo "   ⏭ Skipping provider $name (already exists)"
+    else
+      cp "$src" "$dest"
+    fi
+  done
+  # Also seed .sops.yaml if not present
+  if [ -f "$providers_builtin_dir/.sops.yaml" ] && [ ! -f "$providers_dir/.sops.yaml" ]; then
+    cp "$providers_builtin_dir/.sops.yaml" "$providers_dir/.sops.yaml"
+  fi
+fi
+
 # Clean up derived data that is safe to rebuild.
 # Search indexes and compiled modules are rebuilt automatically from the database.
 # User uploads (files/) are NEVER touched — they contain permanent user data.

From 8970291cbca62378701260cd877ba570b0e5757b Mon Sep 17 00:00:00 2001
From: larryro <371767072@qq.com>
Date: Fri, 3 Apr 2026 10:56:05 +0800
Subject: [PATCH 06/13] refactor(platform): eliminate .sops.yaml, derive public
 key from SOPS_AGE_KEY
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Remove .sops.yaml entirely — encryption now uses `sops -e --age` with
the public key derived at runtime from SOPS_AGE_KEY via X25519. This
collapses the entire SOPS config to a single env var.

- Delete .sops.yaml from repo root and examples/providers/
- saveProviderSecret derives public key and passes --age flag
- Add @noble/curves + @scure/base to platform for key derivation
- Remove dev key fallback from sops.ts (no hardcoded secrets)
- Remove .sops.yaml generation from tale init
- Remove consistency check from tale deploy
- Add providers bind-mount to compose.yml for dev
- Remove .sops.yaml seed from entrypoint
---
 .env.example                                  |  5 ++-
 .sops.yaml                                    |  3 --
 bun.lock                                      |  2 ++
 compose.yml                                   |  2 ++
 examples/providers/.sops.yaml                 |  3 --
 examples/providers/openrouter.secrets.json    |  8 ++---
 services/platform/convex/_generated/api.d.ts  |  2 ++
 services/platform/convex/lib/age_keygen.ts    | 34 +++++++++++++++++++
 services/platform/convex/lib/sops.ts          |  9 -----
 .../platform/convex/providers/file_actions.ts | 12 ++++++-
 services/platform/docker-entrypoint.sh        |  4 ---
 services/platform/package.json                |  2 ++
 tools/cli/src/lib/actions/deploy.ts           | 34 +------------------
 tools/cli/src/lib/actions/init.ts             | 15 +-------
 14 files changed, 61 insertions(+), 74 deletions(-)
 delete mode 100644 .sops.yaml
 delete mode 100644 examples/providers/.sops.yaml
 create mode 100644 services/platform/convex/lib/age_keygen.ts

diff --git a/.env.example b/.env.example
index bcdcbaad44..d3183ba0e2 100644
--- a/.env.example
+++ b/.env.example
@@ -138,9 +138,8 @@ INSTANCE_SECRET=0516d5cddc8b9bbc01238b8696f13c711983f45f6dc4dbf9dc66ba42fc16f504
 # OPTIONAL: Provider Secrets Encryption
 # ============================================================================
 # Age secret key used by SOPS to decrypt provider secret files (*.secrets.json).
-# Auto-generated by tale init. Auto-applied in dev mode if unset.
-# Dev default:
-# SOPS_AGE_KEY=AGE-SECRET-KEY-17FH890V3A765GWGYA3E83UEFZNRZL3T4GJ0Q7WSS5GRGEDE9G83SCV3UTH
+# Auto-generated by tale init, or generate manually with: age-keygen
+# SOPS_AGE_KEY=
 
 # ============================================================================
 # OPTIONAL: Operator Service (Browser Automation)
diff --git a/.sops.yaml b/.sops.yaml
deleted file mode 100644
index be3e656ded..0000000000
--- a/.sops.yaml
+++ /dev/null
@@ -1,3 +0,0 @@
-creation_rules:
-  - path_regex: \.secrets\.json$
-    age: age1xsc5y9x0dref9kd6fwv2356pw2zl5s7gp5v6jam9h4q7mv6fm9aqumvqhj
diff --git a/bun.lock b/bun.lock
index a70806fbb5..02ca90ecc7 100644
--- a/bun.lock
+++ b/bun.lock
@@ -52,6 +52,7 @@
         "@microlink/react-json-view": "1.31.15",
         "@milkdown/crepe": "7.18.0",
         "@milkdown/react": "7.18.0",
+        "@noble/curves": "^1.8.0",
         "@radix-ui/react-checkbox": "1.3.3",
         "@radix-ui/react-dialog": "1.1.15",
         "@radix-ui/react-dropdown-menu": "2.1.16",
@@ -67,6 +68,7 @@
         "@radix-ui/react-toast": "1.2.15",
         "@radix-ui/react-tooltip": "1.2.8",
         "@radix-ui/react-visually-hidden": "1.2.4",
+        "@scure/base": "^1.2.0",
         "@sentry/tanstackstart-react": "^10.37.0",
         "@tailwindcss/postcss": "4.2.2",
         "@tanstack/react-query": "5.96.1",
diff --git a/compose.yml b/compose.yml
index 76aa027286..62807daf59 100644
--- a/compose.yml
+++ b/compose.yml
@@ -287,6 +287,8 @@ services:
       - caddy-data:/caddy-data:ro
       # Development: mount local agents directory to override defaults
       # - ./examples/agents:/app/data/agents
+      # Development: mount local provider configs
+      - ./examples/providers:/app/data/providers
 
     # Environment file
     # Create a .env file in the project root with your configuration
diff --git a/examples/providers/.sops.yaml b/examples/providers/.sops.yaml
deleted file mode 100644
index be3e656ded..0000000000
--- a/examples/providers/.sops.yaml
+++ /dev/null
@@ -1,3 +0,0 @@
-creation_rules:
-  - path_regex: \.secrets\.json$
-    age: age1xsc5y9x0dref9kd6fwv2356pw2zl5s7gp5v6jam9h4q7mv6fm9aqumvqhj
diff --git a/examples/providers/openrouter.secrets.json b/examples/providers/openrouter.secrets.json
index c4d1eed477..1465222b03 100644
--- a/examples/providers/openrouter.secrets.json
+++ b/examples/providers/openrouter.secrets.json
@@ -1,5 +1,5 @@
 {
-	"apiKey": "ENC[AES256_GCM,data:KHZXdpUGs+WM6YEBpLnAlKpa7kcV/macP7jQuaXllb7yAHUp4bI2PEYjYkRalbP5gyy/FfRpUpvieJTTizJNieNAUIBFFT1WJA==,iv:DwsX6dlZ8xrgp/EzJdiZesf747dH8IiOb6/iVa1yCwk=,tag:qy95qyFBdGC8WpGah8m3Fg==,type:str]",
+	"apiKey": "ENC[AES256_GCM,data:5t/po/UofoTQMn/Cgz6ju3Ij6YbuAdOqBmAK/b0SoVGfu7BNKXaJPBJZNC8GGw8OQeMzhDl+DamCM5hl3hd4HFJOdwDwdplFZQ==,iv:9jD1tqi60JYJkb+cY0nY6QNHdnZPQ4F6sQeing5QJ9E=,tag:hdps/EHR4LHUhwzH76mXag==,type:str]",
 	"sops": {
 		"kms": null,
 		"gcp_kms": null,
@@ -8,11 +8,11 @@
 		"age": [
 			{
 				"recipient": "age1xsc5y9x0dref9kd6fwv2356pw2zl5s7gp5v6jam9h4q7mv6fm9aqumvqhj",
-				"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBZMHMyZ1dGeVdDL2FZSVpX\nbDYzM1JtT3doQVBCUFNoQUFwV3o1cWVCS3pFClZlMHNJdER1TDBOK01welMwWFk0\nMUNCUVpSbTFjb2t4TE5RTlFuNFpDdDgKLS0tIEpQL1J5LzZFd2o4UjhqQW53YUh2\nYWMvUmMwMVVjcHJuR09WL2pmNlFnd3MK6glsGfY9oPf2UXgC5FUFD2JrOjXw3pNF\nfdgI8Kus8DMZkOtF7OksUoodwmBlYaeNzQnc3VC5GGgMmyI7QnvaDg==\n-----END AGE ENCRYPTED FILE-----\n"
+				"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4aCtrT1U2SHJiRE9LZjNi\nWUVuZ1JCbGJOUmRiUDBpR3NDTklBUjJxeDNRClEwbDBnY0ErQmM1SzEyM2tsMEJJ\nRVZYQjJ5bGhBMnBvM1RRQmFIV25XWWcKLS0tIEszVUZuaEN1Y3pQWlZodjQzMFJE\nVHlaR3hHRXJwTGh0Vk1KU1JuWkprWnMK/7HmxFH5ciZqhgtfzGyzYhnU08hESOnM\nC1F7mP6UrJ6kyWKFNEdluayhFkTnixTGJ6GrL72tClvrQRAyGuKAEA==\n-----END AGE ENCRYPTED FILE-----\n"
 			}
 		],
-		"lastmodified": "2026-04-03T01:37:55Z",
-		"mac": "ENC[AES256_GCM,data:RXXHQRNr72jWMV90U9w246d8DLmz1qwua+qwg1j/3ZxRoVRBGRMv5PhQ933ueFejpmVFpaMlSsrIR5fJI0g6x3ky1ykwhDTuvAgUEMSyTK/54EExBBTJITCa2OATFjvWyqKnzQ9dZZvoEz/JMo/dJLYeUBuwUYShZOdxc97axbA=,iv:jXHPJVR01g4puGBI/U4AivaXGHlFV9xe3C0CU7rPWmQ=,tag:EdOyEi+oT3lNcLCxuvBcJQ==,type:str]",
+		"lastmodified": "2026-04-03T02:55:08Z",
+		"mac": "ENC[AES256_GCM,data:CAoKo2SqAUL7iVjWylNxhecEhJfBNQAUFqCEMLmBqBRERxCj7nsclIBmyA9KpJR14qrWM8vF7u3aNC95Wwzww1xh17pC31VYPcIdF8bxmbQ/Djkx0/BBPHaXXZcjBnObKsDhfltNZx8G+IBrRshY07CWYqYRzm9ogrkzfFHgIL8=,iv:AnitQlvUD+/Yahxx6HTa/qnlklHYOt4Bwv5WtUgaFsA=,tag:bSA7cW4N1s9FKHh/Sf0QyA==,type:str]",
 		"pgp": null,
 		"unencrypted_suffix": "_unencrypted",
 		"version": "3.9.4"
diff --git a/services/platform/convex/_generated/api.d.ts b/services/platform/convex/_generated/api.d.ts
index 396eb83c29..1641e247be 100644
--- a/services/platform/convex/_generated/api.d.ts
+++ b/services/platform/convex/_generated/api.d.ts
@@ -308,6 +308,7 @@ import type * as integrations_update_sync_stats from "../integrations/update_syn
 import type * as integrations_utils_get_integration_type from "../integrations/utils/get_integration_type.js";
 import type * as integrations_validators from "../integrations/validators.js";
 import type * as lib_action_cache_index from "../lib/action_cache/index.js";
+import type * as lib_age_keygen from "../lib/age_keygen.js";
 import type * as lib_agent_chat_index from "../lib/agent_chat/index.js";
 import type * as lib_agent_chat_internal_actions from "../lib/agent_chat/internal_actions.js";
 import type * as lib_agent_chat_start_agent_chat from "../lib/agent_chat/start_agent_chat.js";
@@ -1144,6 +1145,7 @@ declare const fullApi: ApiFromModules<{
   "integrations/utils/get_integration_type": typeof integrations_utils_get_integration_type;
   "integrations/validators": typeof integrations_validators;
   "lib/action_cache/index": typeof lib_action_cache_index;
+  "lib/age_keygen": typeof lib_age_keygen;
   "lib/agent_chat/index": typeof lib_agent_chat_index;
   "lib/agent_chat/internal_actions": typeof lib_agent_chat_internal_actions;
   "lib/agent_chat/start_agent_chat": typeof lib_agent_chat_start_agent_chat;
diff --git a/services/platform/convex/lib/age_keygen.ts b/services/platform/convex/lib/age_keygen.ts
new file mode 100644
index 0000000000..adeeb0c141
--- /dev/null
+++ b/services/platform/convex/lib/age_keygen.ts
@@ -0,0 +1,34 @@
+'use node';
+
+/**
+ * Age public key derivation from a secret key.
+ *
+ * Used by saveProviderSecret to pass the --age flag to sops -e,
+ * eliminating the need for .sops.yaml files.
+ */
+
+import { x25519 } from '@noble/curves/ed25519';
+import { bech32 } from '@scure/base';
+
+const SECRET_HRP = 'age-secret-key-';
+const PUBLIC_HRP = 'age';
+
+/**
+ * Derive the age public key from an existing secret key string.
+ *
+ * @param secretKey - The "AGE-SECRET-KEY-1..." string
+ * @returns The corresponding "age1..." public key
+ */
+export function deriveAgePublicKey(secretKey: string): string {
+  const lowercase = secretKey.toLowerCase();
+  if (!lowercase.includes('1')) {
+    throw new Error('Invalid age secret key: missing bech32 separator');
+  }
+  const decoded = bech32.decode(lowercase as `${string}1${string}`, false);
+  if (decoded.prefix !== SECRET_HRP) {
+    throw new Error(`Invalid age secret key prefix: "${decoded.prefix}"`);
+  }
+  const secretBytes = bech32.fromWords(decoded.words);
+  const publicBytes = x25519.getPublicKey(new Uint8Array(secretBytes));
+  return bech32.encode(PUBLIC_HRP, bech32.toWords(publicBytes));
+}
diff --git a/services/platform/convex/lib/sops.ts b/services/platform/convex/lib/sops.ts
index d1a1fc4c8a..ab2b33e18a 100644
--- a/services/platform/convex/lib/sops.ts
+++ b/services/platform/convex/lib/sops.ts
@@ -10,10 +10,6 @@
 import { execSync } from 'node:child_process';
 import { stat } from 'node:fs/promises';
 
-/** Dev-only fallback key — matches the public key in the repo's .sops.yaml. */
-const DEV_AGE_KEY =
-  'AGE-SECRET-KEY-17FH890V3A765GWGYA3E83UEFZNRZL3T4GJ0Q7WSS5GRGEDE9G83SCV3UTH';
-
 interface CacheEntry {
   data: Record<string, unknown>;
   mtimeMs: number;
@@ -30,11 +26,6 @@ export async function decryptSecretsFile(
     return cached.data;
   }
 
-  // Auto-set dev fallback key if no key is configured
-  if (!process.env.SOPS_AGE_KEY && !process.env.SOPS_AGE_KEY_FILE) {
-    process.env.SOPS_AGE_KEY = DEV_AGE_KEY;
-  }
-
   let stdout: string;
   try {
     stdout = execSync(`sops -d --output-type json "${filePath}"`, {
diff --git a/services/platform/convex/providers/file_actions.ts b/services/platform/convex/providers/file_actions.ts
index 1b149169e8..169d585957 100644
--- a/services/platform/convex/providers/file_actions.ts
+++ b/services/platform/convex/providers/file_actions.ts
@@ -20,6 +20,7 @@ import type { ProviderJson, ProviderReadResult } from './file_utils';
 import { providerJsonSchema } from '../../lib/shared/schemas/providers';
 import { action, internalAction } from '../_generated/server';
 import { authComponent } from '../auth';
+import { deriveAgePublicKey } from '../lib/age_keygen';
 import { atomicWrite, readJsonFile, sha256 } from '../lib/file_io';
 import { decryptSecretsFile } from '../lib/sops';
 import {
@@ -389,13 +390,22 @@ export const saveProviderSecret = action({
       args.providerName,
     );
 
+    const sopsAgeKey = process.env.SOPS_AGE_KEY;
+    if (!sopsAgeKey) {
+      throw new Error(
+        'SOPS_AGE_KEY environment variable is not set. ' +
+          'Set it in .env to enable provider secret encryption.',
+      );
+    }
+    const agePublicKey = deriveAgePublicKey(sopsAgeKey);
+
     const plaintext = JSON.stringify({ apiKey: args.apiKey }, null, 2) + '\n';
     const { execSync } = await import('node:child_process');
 
     await atomicWrite(secretsPath, plaintext);
 
     try {
-      execSync(`sops -e -i "${secretsPath}"`, {
+      execSync(`sops -e --age "${agePublicKey}" -i "${secretsPath}"`, {
         encoding: 'utf-8',
         timeout: 10_000,
         stdio: ['pipe', 'pipe', 'pipe'],
diff --git a/services/platform/docker-entrypoint.sh b/services/platform/docker-entrypoint.sh
index 7941010281..6d8d0135d9 100644
--- a/services/platform/docker-entrypoint.sh
+++ b/services/platform/docker-entrypoint.sh
@@ -515,10 +515,6 @@ if [ -d "$providers_builtin_dir" ] && [ "$(ls -A "$providers_builtin_dir" 2>/dev
       cp "$src" "$dest"
     fi
   done
-  # Also seed .sops.yaml if not present
-  if [ -f "$providers_builtin_dir/.sops.yaml" ] && [ ! -f "$providers_dir/.sops.yaml" ]; then
-    cp "$providers_builtin_dir/.sops.yaml" "$providers_dir/.sops.yaml"
-  fi
 fi
 
 # Clean up derived data that is safe to rebuild.
diff --git a/services/platform/package.json b/services/platform/package.json
index 954d1e1474..2a5f729f18 100644
--- a/services/platform/package.json
+++ b/services/platform/package.json
@@ -35,6 +35,8 @@
   },
   "dependencies": {
     "@ai-sdk/openai-compatible": "^2.0.37",
+    "@noble/curves": "^1.8.0",
+    "@scure/base": "^1.2.0",
     "@ai-sdk/provider-utils": "^4.0.6",
     "@casl/ability": "^6.8.0",
     "@convex-dev/action-cache": "0.3.0",
diff --git a/tools/cli/src/lib/actions/deploy.ts b/tools/cli/src/lib/actions/deploy.ts
index c0d2d270a0..145495be8a 100644
--- a/tools/cli/src/lib/actions/deploy.ts
+++ b/tools/cli/src/lib/actions/deploy.ts
@@ -1,6 +1,5 @@
-import { existsSync, readFileSync } from 'node:fs';
+import { existsSync } from 'node:fs';
 import { join } from 'node:path';
-import { parse as parseYaml } from 'yaml';
 
 import { PROJECT_NAME, type DeploymentEnv } from '../../utils/load-env';
 import * as logger from '../../utils/logger';
@@ -15,7 +14,6 @@ import {
   isRotatableService,
   isStatefulService,
 } from '../compose/types';
-import { deriveAgePublicKey } from '../crypto/age-keygen';
 import { dockerCompose } from '../docker/docker-compose';
 import { ensureNetwork } from '../docker/ensure-network';
 import { ensureVolumes } from '../docker/ensure-volumes';
@@ -445,9 +443,6 @@ async function syncProjectFiles(
     return;
   }
 
-  // Check SOPS key consistency before syncing
-  checkSopsKeyConsistency(projectDir);
-
   logger.blank();
   logger.step(`${prefix}Syncing project files to ${containerName}...`);
 
@@ -479,30 +474,3 @@ async function syncProjectFiles(
     logger.success('Project files synced');
   }
 }
-
-function checkSopsKeyConsistency(projectDir: string): void {
-  const sopsAgeKey = process.env.SOPS_AGE_KEY;
-  const sopsYamlPath = join(projectDir, 'providers', '.sops.yaml');
-
-  if (!sopsAgeKey || !existsSync(sopsYamlPath)) return;
-
-  try {
-    const sopsYaml = parseYaml(readFileSync(sopsYamlPath, 'utf-8'));
-    const configuredPublicKey = sopsYaml?.creation_rules?.[0]?.age as
-      | string
-      | undefined;
-    if (!configuredPublicKey) return;
-
-    const derivedPublicKey = deriveAgePublicKey(sopsAgeKey);
-    if (derivedPublicKey !== configuredPublicKey) {
-      logger.warn(
-        'SOPS_AGE_KEY in .env does not match the public key in providers/.sops.yaml.',
-      );
-      logger.warn(
-        'Encrypted provider secrets may not decrypt. Reconfigure API keys via the management UI if needed.',
-      );
-    }
-  } catch {
-    // Non-critical check — don't block deployment
-  }
-}
diff --git a/tools/cli/src/lib/actions/init.ts b/tools/cli/src/lib/actions/init.ts
index 4c8a8a8d05..9c9ea4cd86 100644
--- a/tools/cli/src/lib/actions/init.ts
+++ b/tools/cli/src/lib/actions/init.ts
@@ -1,7 +1,6 @@
 import { existsSync } from 'node:fs';
 import { mkdir, readFile, writeFile } from 'node:fs/promises';
 import { dirname, join, resolve } from 'node:path';
-import { stringify } from 'yaml';
 
 import pkg from '../../../package.json';
 import * as logger from '../../utils/logger';
@@ -135,22 +134,10 @@ export async function init(options: InitOptions): Promise<void> {
   await writeEmbeddedFiles(providerConfigFiles, join(target, 'providers'));
 
   // .env setup
-  let agePublicKey: string | undefined;
   if (!options.noEnv) {
     const { ensureEnv } = await import('../config/ensure-env');
     logger.blank();
-    const result = await ensureEnv({ deployDir: target, skipIfExists: true });
-    agePublicKey = result.agePublicKey;
-  }
-
-  // Write .sops.yaml if age keypair was generated
-  if (agePublicKey) {
-    logger.step('Writing SOPS encryption config...');
-    const sopsConfig = stringify({
-      creation_rules: [{ path_regex: '\\.secrets\\.json$', age: agePublicKey }],
-    });
-    await writeFile(join(target, '.sops.yaml'), sopsConfig);
-    await writeFile(join(target, 'providers', '.sops.yaml'), sopsConfig);
+    await ensureEnv({ deployDir: target, skipIfExists: true });
   }
 
   logger.blank();

From 70bd3c0f1b057e3492d205d648e9d33100dc24be Mon Sep 17 00:00:00 2001
From: larryro <371767072@qq.com>
Date: Fri, 3 Apr 2026 14:04:15 +0800
Subject: [PATCH 07/13] fix(platform): pass SOPS_AGE_KEY via Convex env vars
 instead of Docker env

Remove SOPS_AGE_KEY from Docker Compose environment blocks since it is
now managed as a Convex environment variable. Add SOPS_AGE_KEY to the
entrypoint's env var sync list so it gets pushed to the Convex backend.
Also comment out the local providers volume mount for production use.
---
 compose.yml                            | 11 +----------
 services/platform/docker-entrypoint.sh |  2 ++
 2 files changed, 3 insertions(+), 10 deletions(-)

diff --git a/compose.yml b/compose.yml
index 62807daf59..51ae5d8e68 100644
--- a/compose.yml
+++ b/compose.yml
@@ -134,9 +134,6 @@ services:
     env_file:
       - .env
 
-    environment:
-      - SOPS_AGE_KEY=${SOPS_AGE_KEY:-}
-
     # Restart policy
     # Automatically restart the container if it crashes
     restart: unless-stopped
@@ -216,9 +213,6 @@ services:
     env_file:
       - .env
 
-    environment:
-      - SOPS_AGE_KEY=${SOPS_AGE_KEY:-}
-
     # Restart policy
     # Automatically restart the container if it crashes
     restart: unless-stopped
@@ -288,16 +282,13 @@ services:
       # Development: mount local agents directory to override defaults
       # - ./examples/agents:/app/data/agents
       # Development: mount local provider configs
-      - ./examples/providers:/app/data/providers
+      # - ./examples/providers:/app/data/providers
 
     # Environment file
     # Create a .env file in the project root with your configuration
     env_file:
       - .env
 
-    environment:
-      - SOPS_AGE_KEY=${SOPS_AGE_KEY:-}
-
     # Restart policy
     # Automatically restart the container if it crashes
     restart: unless-stopped
diff --git a/services/platform/docker-entrypoint.sh b/services/platform/docker-entrypoint.sh
index 6d8d0135d9..3fd8c46552 100644
--- a/services/platform/docker-entrypoint.sh
+++ b/services/platform/docker-entrypoint.sh
@@ -641,6 +641,8 @@ deploy_convex_functions() {
     "INTEGRATIONS_DIR"
     # Debug flag (enables all debug loggers when set to "true")
     "DEBUG_MODE"
+    # Age secret key for SOPS provider secret encryption/decryption
+    "SOPS_AGE_KEY"
   )
 
   # Fetch current env vars from Convex (output format: KEY=value per line)

From bfbfa1516651d542319a6bbe9777c01fbe0692ed Mon Sep 17 00:00:00 2001
From: larryro <371767072@qq.com>
Date: Fri, 3 Apr 2026 14:49:12 +0800
Subject: [PATCH 08/13] feat(platform): add per-agent provider binding and
 model selector UI

Add optional `provider` field to agent JSON config so agents can pin a
specific LLM provider (e.g. openrouter) instead of searching all
providers. Centralize the read-parse-resolve pattern into a new
`resolveAgentConfig` internal action, removing duplicated filesystem
reads from unified_chat, webhooks, and workflow triggers. Introduce a
ModelSelector component in the chat input that lets users pick from the
agent's supportedModels list, passing the override through to the
backend via a new `modelId` arg.
---
 examples/agents/chat-agent.json               |   1 +
 examples/agents/crm-assistant.json            |   1 +
 examples/agents/file-assistant.json           |   1 +
 examples/agents/integration-assistant.json    |   1 +
 examples/agents/web-assistant.json            |   1 +
 examples/agents/workflow-assistant.json       |   1 +
 .../features/chat/components/chat-input.tsx   |  26 ++--
 .../chat/components/chat-interface.tsx        |   4 +
 .../chat/components/model-selector.tsx        | 134 ++++++++++++++++++
 .../chat/context/chat-layout-context.tsx      |  26 ++++
 .../app/features/chat/hooks/queries.ts        |   1 +
 .../features/chat/hooks/use-send-message.ts   |   4 +
 .../delegation/create_delegation_tool.ts      |   2 +-
 .../delegation/load_delegation_agents.ts      |   2 +-
 .../agent_tools/human_input/mutations.ts      |   1 -
 .../convex/agent_tools/location/mutations.ts  |   1 -
 .../trigger_completion_action.test.ts         |  95 ++-----------
 .../workflows/internal_mutations.ts           |   2 +-
 .../workflows/trigger_completion_action.ts    |  50 +------
 services/platform/convex/agents/config.ts     |   1 +
 .../platform/convex/agents/file_actions.ts    |  52 +++++++
 services/platform/convex/agents/file_utils.ts |   1 +
 services/platform/convex/agents/start_chat.ts |   2 +-
 .../platform/convex/agents/unified_chat.ts    |  63 ++------
 .../agents/webhooks/internal_actions.ts       |  43 +-----
 .../agents/webhooks/internal_mutations.ts     |   2 +-
 .../convex/lib/agent_chat/internal_actions.ts |   7 +-
 .../convex/lib/agent_chat/start_agent_chat.ts |   4 +-
 .../platform/convex/lib/agent_chat/types.ts   |   8 +-
 .../convex/lib/agent_response/types.ts        |   2 +-
 .../platform/convex/providers/file_actions.ts |  37 ++++-
 .../platform/lib/shared/schemas/agents.ts     |   6 +
 services/platform/messages/en.json            |   5 +
 33 files changed, 338 insertions(+), 249 deletions(-)
 create mode 100644 services/platform/app/features/chat/components/model-selector.tsx

diff --git a/examples/agents/chat-agent.json b/examples/agents/chat-agent.json
index c8a45a219e..b5f5405e73 100644
--- a/examples/agents/chat-agent.json
+++ b/examples/agents/chat-agent.json
@@ -18,6 +18,7 @@
     "document_write"
   ],
   "supportedModels": ["moonshotai/kimi-k2.5", "deepseek/deepseek-v3.2"],
+  "provider": "openrouter",
   "knowledgeMode": "tool",
   "includeOrgKnowledge": true,
   "structuredResponsesEnabled": true,
diff --git a/examples/agents/crm-assistant.json b/examples/agents/crm-assistant.json
index 4f33a7a463..46c731b634 100644
--- a/examples/agents/crm-assistant.json
+++ b/examples/agents/crm-assistant.json
@@ -3,6 +3,7 @@
   "displayName": "Sales Assistant",
   "maxSteps": 10,
   "supportedModels": ["anthropic/claude-opus-4.6", "openai/gpt-5.2"],
+  "provider": "openrouter",
   "outputReserve": 2048,
   "structuredResponsesEnabled": false,
   "systemInstructions": "You are a CRM assistant specialized in retrieving customer and product data.\n\n**KNOWLEDGE SCOPE**\nYou access ONLY the organization's internal customer and product database.\nFor data from external systems (Shopify, Salesforce, PMS, etc.), the user needs the Integration Assistant — integrations are configured in [Settings > Integrations]({{site_url}}/dashboard/{{organization.id}}/settings/integrations).\n\n**AVAILABLE TOOLS**\n- customer_read: Read customer data (get_by_id, get_by_email, list operations)\n- product_read: Read product data (get_by_id, list operations)\n\n**ACTION-FIRST PRINCIPLE**\nSearch first, but STOP and ask when multiple matches are found.\n\nALWAYS search first:\n• User mentions a name → search by name/email, don't ask for ID\n• User says \"the customer\" → check conversation context for who they mean\n• Partial info given → use it to search, then proceed\n\n**CRITICAL - MULTIPLE MATCHES:**\nWhen you find 2 or more matching records and the user's request implies ONE specific target:\n1. DO NOT pick one arbitrarily or proceed with all\n2. Return the list of matches with minimal distinguishing details (name, status, source) to help the user identify the correct record\n3. Ask the user to clarify which one they mean\n\nDo NOT ask:\n• For IDs when you have a name to search\n• About scope for simple queries (just return results)\n• For confirmation on obvious single-match requests\n\n**CUSTOMER OPERATIONS**\n- get_by_id: Use when you have a specific customer ID\n- get_by_email: Use when searching by email address\n- list: Use for browsing, filtering, or bulk operations\n\n**PRODUCT OPERATIONS**\n- get_by_id: Use when you have a specific product ID\n- list: Use for browsing the catalog or bulk operations\n\n**BEST PRACTICES**\n1. ALWAYS specify the 'fields' parameter to minimize response size\n2. Avoid 'metadata' field unless specifically requested - it can be very large\n3. Use pagination (cursor) for large datasets instead of fetching all at once\n4. Default numItems is 200; reduce if selecting many fields\n5. If hasMore is true, continue with the returned cursor to fetch more data\n\n**FIELD SELECTION GUIDE**\nCustomer common fields: _id, name, email, status, source, locale\nProduct common fields: _id, name, description, price, currency, status, category\n\nHeavy fields (avoid unless needed):\n- Customer: metadata, address\n- Product: metadata, translations\n\n**RESPONSE GUIDELINES**\n- Present data in clear, structured format (tables for lists)\n- Include pagination info when relevant (hasMore, cursor)\n- Summarize large datasets rather than dumping raw data\n- If data not found, say so clearly\n- Never expose internal IDs unless specifically requested",
diff --git a/examples/agents/file-assistant.json b/examples/agents/file-assistant.json
index d3ba4f186c..2fceff396f 100644
--- a/examples/agents/file-assistant.json
+++ b/examples/agents/file-assistant.json
@@ -16,6 +16,7 @@
     "request_user_location"
   ],
   "supportedModels": ["qwen/qwen3-next-80b-a3b-instruct", "qwen/qwen3.5-35b-a3b"],
+  "provider": "openrouter",
   "knowledgeMode": "tool",
   "includeTeamKnowledge": false,
   "maxSteps": 15,
diff --git a/examples/agents/integration-assistant.json b/examples/agents/integration-assistant.json
index 2b1e6b7354..3d2fa0a5d4 100644
--- a/examples/agents/integration-assistant.json
+++ b/examples/agents/integration-assistant.json
@@ -7,5 +7,6 @@
   "timeoutMs": 180000,
   "outputReserve": 2048,
   "supportedModels": ["qwen/qwen3-next-80b-a3b-instruct", "qwen/qwen3.5-35b-a3b"],
+  "provider": "openrouter",
   "roleRestriction": "admin_developer"
 }
diff --git a/examples/agents/web-assistant.json b/examples/agents/web-assistant.json
index 7a8e4bfad3..073819e125 100644
--- a/examples/agents/web-assistant.json
+++ b/examples/agents/web-assistant.json
@@ -6,6 +6,7 @@
     "web"
   ],
   "supportedModels": ["qwen/qwen3-next-80b-a3b-instruct", "qwen/qwen3.5-35b-a3b"],
+  "provider": "openrouter",
   "structuredResponsesEnabled": false,
   "maxSteps": 5,
   "timeoutMs": 300000,
diff --git a/examples/agents/workflow-assistant.json b/examples/agents/workflow-assistant.json
index 20f219478b..8788636d25 100644
--- a/examples/agents/workflow-assistant.json
+++ b/examples/agents/workflow-assistant.json
@@ -13,6 +13,7 @@
     "run_workflow"
   ],
   "supportedModels": ["anthropic/claude-opus-4.6", "openai/gpt-5.2"],
+  "provider": "openrouter",
   "maxSteps": 30,
   "timeoutMs": 240000,
   "outputReserve": 2048,
diff --git a/services/platform/app/features/chat/components/chat-input.tsx b/services/platform/app/features/chat/components/chat-input.tsx
index fcadbeed7d..cf6941087e 100644
--- a/services/platform/app/features/chat/components/chat-input.tsx
+++ b/services/platform/app/features/chat/components/chat-input.tsx
@@ -25,6 +25,7 @@ import type { FileAttachment } from '../hooks/use-convex-file-upload';
 
 import { AgentSelector } from './agent-selector';
 import { ImagePreviewDialog } from './message-bubble';
+import { ModelSelector } from './model-selector';
 
 interface ChatInputProps extends Omit<
   ComponentPropsWithoutRef<'div'>,
@@ -301,17 +302,20 @@ export function ChatInput({
           </div>
 
           <HStack justify="between" align="center" className="pb-3">
-            <Tooltip content={tDialogs('attach')} side="top">
-              <Button
-                variant="ghost"
-                size="icon"
-                onClick={() => fileInputRef.current?.click()}
-                disabled={inputDisabled}
-                aria-label={tDialogs('attach')}
-              >
-                <Paperclip className="size-4" />
-              </Button>
-            </Tooltip>
+            <HStack gap={2} align="center">
+              <Tooltip content={tDialogs('attach')} side="top">
+                <Button
+                  variant="ghost"
+                  size="icon"
+                  onClick={() => fileInputRef.current?.click()}
+                  disabled={inputDisabled}
+                  aria-label={tDialogs('attach')}
+                >
+                  <Paperclip className="size-4" />
+                </Button>
+              </Tooltip>
+              <ModelSelector organizationId={organizationId} />
+            </HStack>
             <HStack gap={2} align="center">
               <AgentSelector organizationId={organizationId} />
               <Button
diff --git a/services/platform/app/features/chat/components/chat-interface.tsx b/services/platform/app/features/chat/components/chat-interface.tsx
index 8fd3ae23d3..c43599f1ad 100644
--- a/services/platform/app/features/chat/components/chat-interface.tsx
+++ b/services/platform/app/features/chat/components/chat-interface.tsx
@@ -72,6 +72,7 @@ export function ChatInterface({
     clearChatState,
     pendingMessage,
     setPendingMessage,
+    selectedModelOverrides,
   } = useChatLayout();
 
   const effectiveAgent = useEffectiveAgent(organizationId);
@@ -305,6 +306,9 @@ export function ChatInterface({
       resetCancelled();
     },
     selectedAgent: effectiveAgent,
+    modelId: effectiveAgent?.name
+      ? selectedModelOverrides[effectiveAgent.name]
+      : undefined,
     userContext,
   });
 
diff --git a/services/platform/app/features/chat/components/model-selector.tsx b/services/platform/app/features/chat/components/model-selector.tsx
new file mode 100644
index 0000000000..d16efc4b72
--- /dev/null
+++ b/services/platform/app/features/chat/components/model-selector.tsx
@@ -0,0 +1,134 @@
+'use client';
+
+import { ChevronDown, Cpu } from 'lucide-react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+
+import { SearchableSelect } from '@/app/components/ui/forms/searchable-select';
+import { useListProviders } from '@/app/features/settings/providers/hooks/queries';
+import { useT } from '@/lib/i18n/client';
+
+import { useChatLayout } from '../context/chat-layout-context';
+import { useChatAgents } from '../hooks/queries';
+import { useEffectiveAgent } from '../hooks/use-effective-agent';
+
+interface ModelSelectorProps {
+  organizationId: string;
+}
+
+function getModelShortName(modelId: string): string {
+  const slash = modelId.lastIndexOf('/');
+  return slash >= 0 ? modelId.slice(slash + 1) : modelId;
+}
+
+export function ModelSelector({ organizationId }: ModelSelectorProps) {
+  const { t } = useT('chat');
+  const effectiveAgent = useEffectiveAgent(organizationId);
+  const { agents } = useChatAgents(organizationId);
+  const { providers } = useListProviders('default');
+  const { selectedModelOverrides, setSelectedModelOverride } = useChatLayout();
+  const [open, setOpen] = useState(false);
+
+  const supportedModels = useMemo(() => {
+    const agent = agents?.find((a) => a.name === effectiveAgent?.name);
+    return agent?.supportedModels ?? [];
+  }, [agents, effectiveAgent?.name]);
+
+  const modelDisplayNames = useMemo(() => {
+    const map = new Map<string, string>();
+    for (const provider of providers) {
+      if (
+        !provider ||
+        !('models' in provider) ||
+        !Array.isArray(provider.models)
+      )
+        continue;
+      for (const model of provider.models) {
+        map.set(model.id, model.displayName);
+      }
+    }
+    return map;
+  }, [providers]);
+
+  const getDisplayName = useCallback(
+    (modelId: string) =>
+      modelDisplayNames.get(modelId) ?? getModelShortName(modelId),
+    [modelDisplayNames],
+  );
+
+  const currentModelId =
+    (effectiveAgent?.name && selectedModelOverrides[effectiveAgent.name]) ||
+    supportedModels[0] ||
+    null;
+
+  // Clear stale override when agent changes
+  useEffect(() => {
+    if (!effectiveAgent?.name) return;
+    const override = selectedModelOverrides[effectiveAgent.name];
+    if (override && !supportedModels.includes(override)) {
+      setSelectedModelOverride(effectiveAgent.name, null);
+    }
+  }, [
+    effectiveAgent?.name,
+    supportedModels,
+    selectedModelOverrides,
+    setSelectedModelOverride,
+  ]);
+
+  const handleSelect = useCallback(
+    (modelId: string) => {
+      if (!effectiveAgent?.name) return;
+      if (modelId === supportedModels[0]) {
+        setSelectedModelOverride(effectiveAgent.name, null);
+      } else {
+        setSelectedModelOverride(effectiveAgent.name, modelId);
+      }
+    },
+    [effectiveAgent?.name, supportedModels, setSelectedModelOverride],
+  );
+
+  if (!currentModelId) return null;
+
+  const currentLabel = getDisplayName(currentModelId);
+
+  if (supportedModels.length <= 1) {
+    return (
+      <span className="text-muted-foreground flex items-center gap-1.5 text-xs">
+        <Cpu className="size-3.5" aria-hidden="true" />
+        <span>{currentLabel}</span>
+      </span>
+    );
+  }
+
+  const options = supportedModels.map((modelId) => ({
+    value: modelId,
+    label: getDisplayName(modelId),
+  }));
+
+  return (
+    <SearchableSelect
+      value={currentModelId}
+      onValueChange={handleSelect}
+      options={options}
+      open={open}
+      onOpenChange={setOpen}
+      align="start"
+      side="top"
+      sideOffset={8}
+      contentClassName="w-[16.25rem]"
+      searchPlaceholder={t('modelSelector.searchPlaceholder')}
+      emptyText={t('modelSelector.noResults')}
+      aria-label={t('modelSelector.label')}
+      trigger={
+        <button
+          type="button"
+          className="text-muted-foreground hover:text-foreground flex items-center gap-1.5 text-xs transition-colors"
+          aria-label={t('modelSelector.label')}
+        >
+          <Cpu className="size-3.5" aria-hidden="true" />
+          <span>{currentLabel}</span>
+          <ChevronDown className="size-3" aria-hidden="true" />
+        </button>
+      }
+    />
+  );
+}
diff --git a/services/platform/app/features/chat/context/chat-layout-context.tsx b/services/platform/app/features/chat/context/chat-layout-context.tsx
index e47efa16a8..5abe556a20 100644
--- a/services/platform/app/features/chat/context/chat-layout-context.tsx
+++ b/services/platform/app/features/chat/context/chat-layout-context.tsx
@@ -44,6 +44,8 @@ interface ChatLayoutContextType {
   setIsHistoryOpen: (open: boolean) => void;
   selectedAgent: SelectedAgent | null;
   setSelectedAgent: (agent: SelectedAgent | null) => void;
+  selectedModelOverrides: Record<string, string>;
+  setSelectedModelOverride: (agentName: string, modelId: string | null) => void;
 }
 
 const ChatLayoutContext = createContext<ChatLayoutContextType | null>(null);
@@ -78,6 +80,26 @@ export function ChatLayoutProvider({
   const [selectedAgent, setSelectedAgent] =
     usePersistedState<SelectedAgent | null>(agentKey, null);
 
+  const modelOverridesKey = user?.userId
+    ? `selected-models-${user.userId}-${organizationId}`
+    : `selected-models-${organizationId}`;
+  const [selectedModelOverrides, setSelectedModelOverrides] = usePersistedState<
+    Record<string, string>
+  >(modelOverridesKey, {});
+
+  const setSelectedModelOverride = useCallback(
+    (agentName: string, modelId: string | null) => {
+      setSelectedModelOverrides((prev) => {
+        if (modelId === null) {
+          const { [agentName]: _, ...rest } = prev;
+          return rest;
+        }
+        return { ...prev, [agentName]: modelId };
+      });
+    },
+    [setSelectedModelOverrides],
+  );
+
   const clearChatState = useCallback(() => {
     setIsPending(false);
     setPendingThreadId(null);
@@ -97,6 +119,8 @@ export function ChatLayoutProvider({
       setIsHistoryOpen,
       selectedAgent,
       setSelectedAgent,
+      selectedModelOverrides,
+      setSelectedModelOverride,
     }),
     [
       isPending,
@@ -106,6 +130,8 @@ export function ChatLayoutProvider({
       isHistoryOpen,
       selectedAgent,
       setSelectedAgent,
+      selectedModelOverrides,
+      setSelectedModelOverride,
     ],
   );
 
diff --git a/services/platform/app/features/chat/hooks/queries.ts b/services/platform/app/features/chat/hooks/queries.ts
index 80f88e8230..da4cfa1dc8 100644
--- a/services/platform/app/features/chat/hooks/queries.ts
+++ b/services/platform/app/features/chat/hooks/queries.ts
@@ -95,6 +95,7 @@ export function useChatAgents(_organizationId: string) {
           displayName: a.displayName,
           description: a.description,
           visibleInChat: a.visibleInChat,
+          supportedModels: a.supportedModels,
           conversationStarters: a.conversationStarters,
           i18n: a.i18n,
         });
diff --git a/services/platform/app/features/chat/hooks/use-send-message.ts b/services/platform/app/features/chat/hooks/use-send-message.ts
index 7f4ad65649..af4a7fe632 100644
--- a/services/platform/app/features/chat/hooks/use-send-message.ts
+++ b/services/platform/app/features/chat/hooks/use-send-message.ts
@@ -29,6 +29,7 @@ interface UseSendMessageParams {
   clearChatState: () => void;
   onBeforeSend?: () => void;
   selectedAgent: SelectedAgent | null;
+  modelId?: string;
   userContext?: UserContext;
 }
 
@@ -46,6 +47,7 @@ export function useSendMessage({
   clearChatState,
   onBeforeSend,
   selectedAgent,
+  modelId,
   userContext,
 }: UseSendMessageParams) {
   const { t } = useT('chat');
@@ -151,6 +153,7 @@ export function useSendMessage({
           threadId: currentThreadId,
           organizationId,
           message: sanitizedContent,
+          modelId: modelId || undefined,
           attachments: mutationAttachments,
           userContext: userContext
             ? {
@@ -181,6 +184,7 @@ export function useSendMessage({
       updateThread,
       chatWithAgent,
       selectedAgent,
+      modelId,
       userContext,
       navigate,
       t,
diff --git a/services/platform/convex/agent_tools/delegation/create_delegation_tool.ts b/services/platform/convex/agent_tools/delegation/create_delegation_tool.ts
index 78fd5e83fc..546527fc04 100644
--- a/services/platform/convex/agent_tools/delegation/create_delegation_tool.ts
+++ b/services/platform/convex/agent_tools/delegation/create_delegation_tool.ts
@@ -40,7 +40,7 @@ export interface DelegateAgentMeta {
   description: string;
   agentConfig: SerializableAgentConfig;
   model: string;
-  provider: string;
+  provider?: string;
   roleRestriction?: 'admin_developer';
 }
 
diff --git a/services/platform/convex/agent_tools/delegation/load_delegation_agents.ts b/services/platform/convex/agent_tools/delegation/load_delegation_agents.ts
index 268aef5ab9..c987fa7ae5 100644
--- a/services/platform/convex/agent_tools/delegation/load_delegation_agents.ts
+++ b/services/platform/convex/agent_tools/delegation/load_delegation_agents.ts
@@ -46,7 +46,7 @@ export async function loadDelegateAgents(
         description: config.description ?? '',
         agentConfig,
         model: agentConfig.model ?? '',
-        provider: 'openai',
+        provider: agentConfig.provider,
         roleRestriction: config.roleRestriction,
       });
     } catch {
diff --git a/services/platform/convex/agent_tools/human_input/mutations.ts b/services/platform/convex/agent_tools/human_input/mutations.ts
index 830060e762..02920a5249 100644
--- a/services/platform/convex/agent_tools/human_input/mutations.ts
+++ b/services/platform/convex/agent_tools/human_input/mutations.ts
@@ -209,7 +209,6 @@ export const submitHumanInputResponse = mutation({
         agentType: 'custom',
         agentConfig,
         model: agentConfig.model,
-        provider: 'file',
         debugTag: '[ChatAgent:HumanInput]',
         enableStreaming: true,
         hooks: { beforeGenerate },
diff --git a/services/platform/convex/agent_tools/location/mutations.ts b/services/platform/convex/agent_tools/location/mutations.ts
index 8955e20bb1..4188e5ac75 100644
--- a/services/platform/convex/agent_tools/location/mutations.ts
+++ b/services/platform/convex/agent_tools/location/mutations.ts
@@ -172,7 +172,6 @@ export const submitLocationResponse = mutation({
         agentType: 'custom',
         agentConfig,
         model: agentConfig.model,
-        provider: 'file',
         debugTag: '[ChatAgent:Location]',
         enableStreaming: true,
         hooks: { beforeGenerate },
diff --git a/services/platform/convex/agent_tools/workflows/__tests__/trigger_completion_action.test.ts b/services/platform/convex/agent_tools/workflows/__tests__/trigger_completion_action.test.ts
index 063fb58c8d..57e5c071b1 100644
--- a/services/platform/convex/agent_tools/workflows/__tests__/trigger_completion_action.test.ts
+++ b/services/platform/convex/agent_tools/workflows/__tests__/trigger_completion_action.test.ts
@@ -1,15 +1,10 @@
 import { describe, expect, it, vi, beforeEach } from 'vitest';
 
-vi.mock('node:fs/promises', () => ({
-  stat: vi.fn(),
-  readFile: vi.fn(),
-}));
-
 vi.mock('../../../_generated/api', () => ({
   internal: {
     agents: {
-      internal_queries: {
-        getBindingByAgent: 'mock-getBindingByAgent',
+      file_actions: {
+        resolveAgentConfig: 'mock-resolveAgentConfig',
       },
     },
     agent_tools: {
@@ -27,28 +22,6 @@ vi.mock('../../../_generated/server', () => ({
   internalAction: vi.fn((def) => ({ _handler: def.handler })),
 }));
 
-vi.mock('../../../agents/config', () => ({
-  toSerializableConfig: vi.fn(
-    (slug: string, _config: unknown, _binding: unknown) => ({
-      name: slug,
-      instructions: 'test',
-    }),
-  ),
-}));
-
-vi.mock('../../../agents/file_utils', () => ({
-  MAX_FILE_SIZE_BYTES: 1024 * 1024,
-  parseAgentJson: vi.fn(() => ({
-    displayName: 'Test Agent',
-    systemInstructions: 'test',
-    toolNames: [],
-  })),
-  resolveAgentFilePath: vi.fn(
-    (_org: string, slug: string) => `/agents/${slug}.json`,
-  ),
-}));
-
-const { stat, readFile } = await import('node:fs/promises');
 const { triggerCompletionWithAgent } =
   await import('../trigger_completion_action');
 
@@ -58,9 +31,9 @@ const handler = (
   }
 )._handler;
 
-function createMockCtx(binding: Record<string, unknown> | null = null) {
+function createMockCtx(agentConfig: Record<string, unknown> | null = null) {
   return {
-    runQuery: vi.fn().mockResolvedValue(binding),
+    runAction: vi.fn().mockResolvedValue(agentConfig),
     runMutation: vi.fn().mockResolvedValue(undefined),
   };
 }
@@ -70,11 +43,9 @@ describe('triggerCompletionWithAgent', () => {
     vi.clearAllMocks();
   });
 
-  it('reads agent file, resolves binding, and triggers completion', async () => {
-    vi.mocked(stat).mockResolvedValue({ size: 100 } as never);
-    vi.mocked(readFile).mockResolvedValue('{}' as never);
-
-    const ctx = createMockCtx({ teamId: 'team-1', knowledgeFiles: [] });
+  it('resolves agent config and triggers completion', async () => {
+    const mockConfig = { name: 'my-agent', instructions: 'test' };
+    const ctx = createMockCtx(mockConfig);
 
     await handler(
       ctx as never,
@@ -86,11 +57,10 @@ describe('triggerCompletionWithAgent', () => {
       } as never,
     );
 
-    expect(stat).toHaveBeenCalledWith('/agents/my-agent.json');
-    expect(readFile).toHaveBeenCalledWith('/agents/my-agent.json', 'utf-8');
-    expect(ctx.runQuery).toHaveBeenCalledWith('mock-getBindingByAgent', {
-      organizationId: 'org-1',
+    expect(ctx.runAction).toHaveBeenCalledWith('mock-resolveAgentConfig', {
+      orgSlug: 'default',
       agentSlug: 'my-agent',
+      organizationId: 'org-1',
     });
     expect(ctx.runMutation).toHaveBeenCalledWith(
       'mock-triggerWorkflowCompletionResponse',
@@ -99,33 +69,16 @@ describe('triggerCompletionWithAgent', () => {
         organizationId: 'org-1',
         agentSlug: 'my-agent',
         messageContent: 'Workflow completed',
+        agentConfig: mockConfig,
       }),
     );
   });
 
-  it('works without a binding (no knowledge files)', async () => {
-    vi.mocked(stat).mockResolvedValue({ size: 100 } as never);
-    vi.mocked(readFile).mockResolvedValue('{}' as never);
-
-    const ctx = createMockCtx(null);
-
-    await handler(
-      ctx as never,
-      {
-        threadId: 'thread-1',
-        organizationId: 'org-1',
-        agentSlug: 'my-agent',
-        messageContent: 'Done',
-      } as never,
-    );
-
-    expect(ctx.runMutation).toHaveBeenCalledTimes(1);
-  });
-
-  it('throws when agent file is not found', async () => {
-    vi.mocked(stat).mockRejectedValue(new Error('ENOENT'));
-
+  it('propagates errors from resolveAgentConfig', async () => {
     const ctx = createMockCtx();
+    ctx.runAction.mockRejectedValue(
+      new Error('Agent not found: missing-agent'),
+    );
 
     await expect(
       handler(
@@ -139,22 +92,4 @@ describe('triggerCompletionWithAgent', () => {
       ),
     ).rejects.toThrow('Agent not found: missing-agent');
   });
-
-  it('throws when agent file is too large', async () => {
-    vi.mocked(stat).mockResolvedValue({ size: 10 * 1024 * 1024 } as never);
-
-    const ctx = createMockCtx();
-
-    await expect(
-      handler(
-        ctx as never,
-        {
-          threadId: 'thread-1',
-          organizationId: 'org-1',
-          agentSlug: 'big-agent',
-          messageContent: 'Done',
-        } as never,
-      ),
-    ).rejects.toThrow('Agent not found: big-agent');
-  });
 });
diff --git a/services/platform/convex/agent_tools/workflows/internal_mutations.ts b/services/platform/convex/agent_tools/workflows/internal_mutations.ts
index 78a3dc017c..b8b73cbc04 100644
--- a/services/platform/convex/agent_tools/workflows/internal_mutations.ts
+++ b/services/platform/convex/agent_tools/workflows/internal_mutations.ts
@@ -122,7 +122,7 @@ export const triggerWorkflowCompletionResponse = internalMutation({
         agentType: 'custom',
         agentConfig,
         model: agentConfig.model ?? 'default',
-        provider: 'file',
+        provider: agentConfig.provider,
         debugTag: `[${agentSlug}:WorkflowComplete]`,
         enableStreaming: true,
         threadId,
diff --git a/services/platform/convex/agent_tools/workflows/trigger_completion_action.ts b/services/platform/convex/agent_tools/workflows/trigger_completion_action.ts
index d8e36ed337..085da32332 100644
--- a/services/platform/convex/agent_tools/workflows/trigger_completion_action.ts
+++ b/services/platform/convex/agent_tools/workflows/trigger_completion_action.ts
@@ -1,25 +1,15 @@
-'use node';
-
 /**
  * Internal action for triggering workflow completion agent response.
  *
- * Reads the agent config from the JSON file and delegates to the
- * triggerWorkflowCompletionResponse mutation with the full config.
- * This action bridges the filesystem read (requires Node.js) with
- * the transactional mutation that saves messages and schedules generation.
+ * Delegates filesystem I/O to resolveAgentConfig, then passes the
+ * resolved config to the transactional mutation that saves messages
+ * and schedules generation.
  */
 
 import { v } from 'convex/values';
-import { readFile, stat } from 'node:fs/promises';
 
 import { internal } from '../../_generated/api';
 import { internalAction } from '../../_generated/server';
-import { toSerializableConfig } from '../../agents/config';
-import {
-  MAX_FILE_SIZE_BYTES,
-  parseAgentJson,
-  resolveAgentFilePath,
-} from '../../agents/file_utils';
 
 const DEFAULT_ORG_SLUG = 'default';
 
@@ -31,41 +21,15 @@ export const triggerCompletionWithAgent = internalAction({
     messageContent: v.string(),
   },
   handler: async (ctx, args): Promise<void> => {
-    const filePath = resolveAgentFilePath(DEFAULT_ORG_SLUG, args.agentSlug);
-    let content: string;
-    try {
-      const fileStat = await stat(filePath);
-      if (fileStat.size > MAX_FILE_SIZE_BYTES) {
-        throw new Error('Agent file too large');
-      }
-      content = await readFile(filePath, 'utf-8');
-    } catch (err) {
-      const detail = err instanceof Error ? err.message : 'unknown error';
-      throw new Error(`Agent not found: ${args.agentSlug} — ${detail}`, {
-        cause: err,
-      });
-    }
-    const config = parseAgentJson(content);
-
-    const binding = await ctx.runQuery(
-      internal.agents.internal_queries.getBindingByAgent,
+    const agentConfig = await ctx.runAction(
+      internal.agents.file_actions.resolveAgentConfig,
       {
-        organizationId: args.organizationId,
+        orgSlug: DEFAULT_ORG_SLUG,
         agentSlug: args.agentSlug,
+        organizationId: args.organizationId,
       },
     );
 
-    const agentConfig = toSerializableConfig(
-      args.agentSlug,
-      config,
-      binding
-        ? {
-            teamId: binding.teamId ?? undefined,
-            knowledgeFiles: binding.knowledgeFiles ?? undefined,
-          }
-        : undefined,
-    );
-
     await ctx.runMutation(
       internal.agent_tools.workflows.internal_mutations
         .triggerWorkflowCompletionResponse,
diff --git a/services/platform/convex/agents/config.ts b/services/platform/convex/agents/config.ts
index 27f546de0e..9e019aaf25 100644
--- a/services/platform/convex/agents/config.ts
+++ b/services/platform/convex/agents/config.ts
@@ -32,6 +32,7 @@ export function toSerializableConfig(
       (() => {
         throw new Error('supportedModels must not be empty');
       })(),
+    provider: config.provider,
     maxSteps: config.maxSteps,
     enableVectorSearch: false,
     knowledgeMode,
diff --git a/services/platform/convex/agents/file_actions.ts b/services/platform/convex/agents/file_actions.ts
index f473024924..85bec32e60 100644
--- a/services/platform/convex/agents/file_actions.ts
+++ b/services/platform/convex/agents/file_actions.ts
@@ -12,9 +12,11 @@ import { v } from 'convex/values';
 import { mkdir, readdir, rm, unlink } from 'node:fs/promises';
 import path from 'node:path';
 
+import type { SerializableAgentConfig } from '../lib/agent_chat/types';
 import type { AgentJsonConfig, AgentReadResult } from './file_utils';
 
 import { agentJsonSchema } from '../../lib/shared/schemas/agents';
+import { internal } from '../_generated/api';
 import { action, internalAction } from '../_generated/server';
 import { authComponent } from '../auth';
 import {
@@ -382,6 +384,56 @@ export const readAgentForChat = internalAction({
   },
 });
 
+/**
+ * Read agent config from filesystem, fetch DB binding, and return
+ * a fully resolved SerializableAgentConfig ready for the agent pipeline.
+ *
+ * This centralizes the read-parse-convert pattern so callers don't need
+ * Node.js filesystem access.
+ */
+export const resolveAgentConfig = internalAction({
+  args: {
+    orgSlug: v.string(),
+    agentSlug: v.string(),
+    organizationId: v.string(),
+    modelId: v.optional(v.string()),
+  },
+  returns: v.any(),
+  handler: async (ctx, args): Promise<SerializableAgentConfig> => {
+    const result = await readAgentFile(args.orgSlug, args.agentSlug);
+    if (!result.ok) {
+      throw new Error(`Agent not found: ${args.agentSlug} — ${result.message}`);
+    }
+
+    const binding = await ctx.runQuery(
+      internal.agents.internal_queries.getBindingByAgent,
+      {
+        organizationId: args.organizationId,
+        agentSlug: args.agentSlug,
+      },
+    );
+
+    const { toSerializableConfig } = await import('./config');
+    const config = toSerializableConfig(
+      args.agentSlug,
+      result.config,
+      binding
+        ? {
+            teamId: binding.teamId ?? undefined,
+            knowledgeFiles: binding.knowledgeFiles ?? undefined,
+          }
+        : undefined,
+    );
+
+    // Apply model override if requested and allowed by agent's supportedModels
+    if (args.modelId && result.config.supportedModels.includes(args.modelId)) {
+      config.model = args.modelId;
+    }
+
+    return config;
+  },
+});
+
 // ---------------------------------------------------------------------------
 // AI-assisted translation for agent content fields
 // ---------------------------------------------------------------------------
diff --git a/services/platform/convex/agents/file_utils.ts b/services/platform/convex/agents/file_utils.ts
index 051d6745e1..24dc6e5370 100644
--- a/services/platform/convex/agents/file_utils.ts
+++ b/services/platform/convex/agents/file_utils.ts
@@ -34,6 +34,7 @@ export interface AgentJsonConfig {
   delegates?: string[];
   workflows?: string[];
   supportedModels: string[];
+  provider?: string;
   knowledgeMode?: 'off' | 'tool' | 'context' | 'both';
   webSearchMode?: 'off' | 'tool' | 'context' | 'both';
   includeOrgKnowledge?: boolean;
diff --git a/services/platform/convex/agents/start_chat.ts b/services/platform/convex/agents/start_chat.ts
index 57369a437a..9929cc1213 100644
--- a/services/platform/convex/agents/start_chat.ts
+++ b/services/platform/convex/agents/start_chat.ts
@@ -72,7 +72,7 @@ export const startChat = internalMutation({
       userContext: args.userContext,
       agentConfig: args.agentConfig,
       model: args.agentConfig.model ?? 'default',
-      provider: 'file',
+      provider: args.agentConfig.provider,
       agentSlug: args.agentSlug,
       debugTag: `[${args.agentSlug}]`,
       enableStreaming: true,
diff --git a/services/platform/convex/agents/unified_chat.ts b/services/platform/convex/agents/unified_chat.ts
index f464a053a9..b0a23eedf9 100644
--- a/services/platform/convex/agents/unified_chat.ts
+++ b/services/platform/convex/agents/unified_chat.ts
@@ -1,25 +1,16 @@
-'use node';
-
 /**
  * Unified Chat Action
  *
  * Single entry point for chatting with any agent.
- * Reads agent config from JSON file, then delegates to startAgentChat
- * via an internal mutation for transactional stream/message creation.
+ * Delegates filesystem I/O to resolveAgentConfig (Node action),
+ * then starts the chat via an internal mutation.
  */
 
 import { v } from 'convex/values';
-import { readFile, stat } from 'node:fs/promises';
 
 import { internal } from '../_generated/api';
 import { action } from '../_generated/server';
 import { authComponent } from '../auth';
-import { toSerializableConfig } from './config';
-import {
-  MAX_FILE_SIZE_BYTES,
-  parseAgentJson,
-  resolveAgentFilePath,
-} from './file_utils';
 
 export const chatWithAgent = action({
   args: {
@@ -39,6 +30,7 @@ export const chatWithAgent = action({
         }),
       ),
     ),
+    modelId: v.optional(v.string()),
     additionalContext: v.optional(v.record(v.string(), v.string())),
     userContext: v.optional(
       v.object({
@@ -58,55 +50,16 @@ export const chatWithAgent = action({
     const authUser = await authComponent.getAuthUser(ctx);
     if (!authUser) throw new Error('Unauthenticated');
 
-    // Read agent config from filesystem
-    const filePath = resolveAgentFilePath(args.orgSlug, args.agentSlug);
-    let content: string;
-    try {
-      const fileStat = await stat(filePath);
-      if (fileStat.size > MAX_FILE_SIZE_BYTES) {
-        throw new Error('Agent file too large');
-      }
-      content = await readFile(filePath, 'utf-8');
-    } catch (err) {
-      throw new Error(
-        `Agent not found: ${args.agentSlug} — ${err instanceof Error ? err.message : String(err)}`,
-        { cause: err },
-      );
-    }
-    const config = parseAgentJson(content);
-
-    // Check role restriction
-    if (config.roleRestriction === 'admin_developer') {
-      // Role check would go here — for now, allow all authenticated users
-      // TODO: implement role check via ctx.runQuery
-    }
-
-    // Read optional DB binding for knowledge files
-    const binding = await ctx.runQuery(
-      internal.agents.internal_queries.getBindingByAgent,
+    const agentConfig = await ctx.runAction(
+      internal.agents.file_actions.resolveAgentConfig,
       {
-        organizationId: args.organizationId,
+        orgSlug: args.orgSlug,
         agentSlug: args.agentSlug,
+        organizationId: args.organizationId,
+        modelId: args.modelId,
       },
     );
 
-    // Check team access if binding has a teamId
-    if (binding?.teamId) {
-      // Team access check would go here
-      // TODO: implement team access check via ctx.runQuery
-    }
-
-    const agentConfig = toSerializableConfig(
-      args.agentSlug,
-      config,
-      binding
-        ? {
-            teamId: binding.teamId ?? undefined,
-            knowledgeFiles: binding.knowledgeFiles ?? undefined,
-          }
-        : undefined,
-    );
-
     // Delegate to the internal mutation for transactional chat start
     return ctx.runMutation(internal.agents.start_chat.startChat, {
       threadId: args.threadId,
diff --git a/services/platform/convex/agents/webhooks/internal_actions.ts b/services/platform/convex/agents/webhooks/internal_actions.ts
index c01aeec87d..0a6ef1a973 100644
--- a/services/platform/convex/agents/webhooks/internal_actions.ts
+++ b/services/platform/convex/agents/webhooks/internal_actions.ts
@@ -1,17 +1,8 @@
-'use node';
-
 import { v } from 'convex/values';
-import { stat, readFile } from 'node:fs/promises';
 
 import { isRecord, getString } from '../../../lib/utils/type-guards';
 import { components, internal } from '../../_generated/api';
 import { internalAction } from '../../_generated/server';
-import { toSerializableConfig } from '../config';
-import {
-  MAX_FILE_SIZE_BYTES,
-  parseAgentJson,
-  resolveAgentFilePath,
-} from '../file_utils';
 
 export const chatViaWebhook = internalAction({
   args: {
@@ -51,41 +42,15 @@ export const chatViaWebhook = internalAction({
       throw new Error('Organization not found');
     }
 
-    const filePath = resolveAgentFilePath(orgSlug, args.agentSlug);
-    let content: string;
-    try {
-      const fileStat = await stat(filePath);
-      if (fileStat.size > MAX_FILE_SIZE_BYTES) {
-        throw new Error('Agent file too large');
-      }
-      content = await readFile(filePath, 'utf-8');
-    } catch (err) {
-      const detail = err instanceof Error ? err.message : 'unknown error';
-      throw new Error(`Agent not found: ${args.agentSlug} — ${detail}`, {
-        cause: err,
-      });
-    }
-    const config = parseAgentJson(content);
-
-    const binding = await ctx.runQuery(
-      internal.agents.internal_queries.getBindingByAgent,
+    const agentConfig = await ctx.runAction(
+      internal.agents.file_actions.resolveAgentConfig,
       {
-        organizationId: args.organizationId,
+        orgSlug,
         agentSlug: args.agentSlug,
+        organizationId: args.organizationId,
       },
     );
 
-    const agentConfig = toSerializableConfig(
-      args.agentSlug,
-      config,
-      binding
-        ? {
-            teamId: binding.teamId ?? undefined,
-            knowledgeFiles: binding.knowledgeFiles ?? undefined,
-          }
-        : undefined,
-    );
-
     return ctx.runMutation(
       internal.agents.webhooks.internal_mutations.startWebhookChat,
       {
diff --git a/services/platform/convex/agents/webhooks/internal_mutations.ts b/services/platform/convex/agents/webhooks/internal_mutations.ts
index 023546a49d..9f092cb3e4 100644
--- a/services/platform/convex/agents/webhooks/internal_mutations.ts
+++ b/services/platform/convex/agents/webhooks/internal_mutations.ts
@@ -58,7 +58,7 @@ export const startWebhookChat = internalMutation({
       attachments: args.attachments,
       agentConfig: args.agentConfig,
       model: args.agentConfig.model ?? 'default',
-      provider: 'file',
+      provider: args.agentConfig.provider,
       agentSlug: args.agentSlug,
       debugTag: `[${args.agentSlug}:webhook]`,
       enableStreaming: args.enableStreaming ?? true,
diff --git a/services/platform/convex/lib/agent_chat/internal_actions.ts b/services/platform/convex/lib/agent_chat/internal_actions.ts
index 1ad5699cd3..97c94f66ee 100644
--- a/services/platform/convex/lib/agent_chat/internal_actions.ts
+++ b/services/platform/convex/lib/agent_chat/internal_actions.ts
@@ -63,6 +63,7 @@ const serializableAgentConfigValidator = v.object({
   integrationBindings: v.optional(v.array(v.string())),
   workflowBindings: v.optional(v.array(v.string())),
   model: v.optional(v.string()),
+  provider: v.optional(v.string()),
   maxSteps: v.optional(v.number()),
   outputFormat: v.optional(v.union(v.literal('text'), v.literal('json'))),
   enableVectorSearch: v.optional(v.boolean()),
@@ -103,7 +104,7 @@ export const runAgentGeneration = internalAction({
     agentType: v.string(),
     agentConfig: serializableAgentConfigValidator,
     model: v.string(),
-    provider: v.string(),
+    provider: v.optional(v.string()),
     debugTag: v.string(),
     enableStreaming: v.optional(v.boolean()),
     hooks: v.optional(hooksConfigValidator),
@@ -284,7 +285,7 @@ export const runAgentGeneration = internalAction({
     const modelData = modelId
       ? ((await ctx.runAction(
           internal.providers.file_actions.resolveModelData,
-          { modelId },
+          { modelId, providerName: agentConfig.provider },
         )) as {
           providerName: string;
           baseUrl: string;
@@ -294,7 +295,7 @@ export const runAgentGeneration = internalAction({
         })
       : ((await ctx.runAction(
           internal.providers.file_actions.resolveModelByTag,
-          { tag: 'chat' },
+          { tag: 'chat', providerName: agentConfig.provider },
         )) as {
           providerName: string;
           baseUrl: string;
diff --git a/services/platform/convex/lib/agent_chat/start_agent_chat.ts b/services/platform/convex/lib/agent_chat/start_agent_chat.ts
index 62a1ac1a03..a550e6976f 100644
--- a/services/platform/convex/lib/agent_chat/start_agent_chat.ts
+++ b/services/platform/convex/lib/agent_chat/start_agent_chat.ts
@@ -69,8 +69,8 @@ export interface StartAgentChatArgs {
   agentConfig: SerializableAgentConfig;
   /** Model to use for generation */
   model: string;
-  /** Model provider (e.g., 'openai') */
-  provider: string;
+  /** Model provider name (e.g., 'openrouter'). Omit to search all providers. */
+  provider?: string;
   /** Debug tag for logging */
   debugTag: string;
   /** Enable streaming response */
diff --git a/services/platform/convex/lib/agent_chat/types.ts b/services/platform/convex/lib/agent_chat/types.ts
index 7956efc7db..5e7d31b779 100644
--- a/services/platform/convex/lib/agent_chat/types.ts
+++ b/services/platform/convex/lib/agent_chat/types.ts
@@ -25,6 +25,8 @@ export interface SerializableAgentConfig {
   workflowBindings?: string[];
   /** Explicit model override */
   model?: string;
+  /** Explicit provider name (matches provider filename, e.g. 'openrouter') */
+  provider?: string;
   /** Maximum number of steps for tool calls */
   maxSteps?: number;
   /** Output format (text or json) */
@@ -77,8 +79,8 @@ export interface AgentRuntimeConfig {
   agentConfig: SerializableAgentConfig;
   /** Model to use for response generation */
   model: string;
-  /** Model provider (e.g., 'openai', 'anthropic') */
-  provider: string;
+  /** Model provider name (e.g., 'openrouter'). Omit to search all providers. */
+  provider?: string;
   /** Debug tag for logging */
   debugTag: string;
   /** Enable streaming response */
@@ -95,7 +97,7 @@ export interface RunAgentGenerationArgs {
   agentType: string;
   agentConfig: SerializableAgentConfig;
   model: string;
-  provider: string;
+  provider?: string;
   debugTag: string;
   enableStreaming?: boolean;
   hooks?: AgentHooksConfig;
diff --git a/services/platform/convex/lib/agent_response/types.ts b/services/platform/convex/lib/agent_response/types.ts
index 9c6d2153e4..a1ef1df8bb 100644
--- a/services/platform/convex/lib/agent_response/types.ts
+++ b/services/platform/convex/lib/agent_response/types.ts
@@ -17,7 +17,7 @@ export interface GenerateResponseConfig {
   agentType: AgentType;
   createAgent: (options?: Record<string, unknown>) => Agent;
   model: string;
-  provider: string;
+  provider?: string;
   debugTag: string;
   enableStreaming?: boolean;
   hooks?: GenerateResponseHooks;
diff --git a/services/platform/convex/providers/file_actions.ts b/services/platform/convex/providers/file_actions.ts
index 169d585957..f7800a1cea 100644
--- a/services/platform/convex/providers/file_actions.ts
+++ b/services/platform/convex/providers/file_actions.ts
@@ -233,13 +233,25 @@ export const resolveModelData = internalAction({
   args: {
     modelId: v.string(),
     orgSlug: v.optional(v.string()),
+    providerName: v.optional(v.string()),
   },
   returns: v.any(),
   handler: async (_ctx, args) => {
     const orgSlug = args.orgSlug ?? 'default';
     const providers = await loadAllProviders(orgSlug);
 
-    for (const provider of providers) {
+    const candidates = args.providerName
+      ? providers.filter((p) => p.name === args.providerName)
+      : providers;
+
+    if (args.providerName && candidates.length === 0) {
+      const available = providers.map((p) => p.name);
+      throw new Error(
+        `Provider "${args.providerName}" not found. Available: ${available.join(', ')}`,
+      );
+    }
+
+    for (const provider of candidates) {
       const definition = provider.config.models.find(
         (m) => m.id === args.modelId,
       );
@@ -255,11 +267,11 @@ export const resolveModelData = internalAction({
       }
     }
 
-    const allModelIds = providers.flatMap((p) =>
+    const allModelIds = candidates.flatMap((p) =>
       p.config.models.map((m) => m.id),
     );
     throw new Error(
-      `Model "${args.modelId}" not found in any provider. Available: ${allModelIds.join(', ')}`,
+      `Model "${args.modelId}" not found${args.providerName ? ` in provider "${args.providerName}"` : ' in any provider'}. Available: ${allModelIds.join(', ')}`,
     );
   },
 });
@@ -271,12 +283,25 @@ export const resolveModelByTag = internalAction({
   args: {
     tag: v.string(),
     orgSlug: v.optional(v.string()),
+    providerName: v.optional(v.string()),
   },
   returns: v.any(),
   handler: async (_ctx, args) => {
     const orgSlug = args.orgSlug ?? 'default';
     const providers = await loadAllProviders(orgSlug);
-    for (const provider of providers) {
+
+    const candidates = args.providerName
+      ? providers.filter((p) => p.name === args.providerName)
+      : providers;
+
+    if (args.providerName && candidates.length === 0) {
+      const available = providers.map((p) => p.name);
+      throw new Error(
+        `Provider "${args.providerName}" not found. Available: ${available.join(', ')}`,
+      );
+    }
+
+    for (const provider of candidates) {
       const definition = provider.config.models.find((m) =>
         (m.tags as readonly string[]).includes(args.tag),
       );
@@ -293,7 +318,9 @@ export const resolveModelByTag = internalAction({
       }
     }
 
-    throw new Error(`No model with tag "${args.tag}" found in any provider.`);
+    throw new Error(
+      `No model with tag "${args.tag}" found${args.providerName ? ` in provider "${args.providerName}"` : ' in any provider'}.`,
+    );
   },
 });
 
diff --git a/services/platform/lib/shared/schemas/agents.ts b/services/platform/lib/shared/schemas/agents.ts
index d4a4134160..67bd4dac3e 100644
--- a/services/platform/lib/shared/schemas/agents.ts
+++ b/services/platform/lib/shared/schemas/agents.ts
@@ -32,6 +32,12 @@ export const agentJsonSchema = z.object({
   delegates: z.array(z.string()).optional(),
   workflows: z.array(z.string()).optional(),
   supportedModels: z.array(z.string().min(1)).min(1),
+  provider: z
+    .string()
+    .min(1)
+    .max(100)
+    .regex(/^[a-z0-9][a-z0-9_-]*$/)
+    .optional(),
   knowledgeMode: retrievalModeSchema.optional(),
   webSearchMode: retrievalModeSchema.optional(),
   includeOrgKnowledge: z.boolean().optional(),
diff --git a/services/platform/messages/en.json b/services/platform/messages/en.json
index a424b8fcfa..fd2a6a4079 100644
--- a/services/platform/messages/en.json
+++ b/services/platform/messages/en.json
@@ -2504,6 +2504,11 @@
         }
       }
     },
+    "modelSelector": {
+      "label": "Select model",
+      "searchPlaceholder": "Search models...",
+      "noResults": "No models found"
+    },
     "searchChat": "Search chat",
     "hideSearch": "Hide search",
     "showHistory": "Show history",

From fc6a7ceeb851a87428505d1114e32ccc72f9031c Mon Sep 17 00:00:00 2001
From: larryro <371767072@qq.com>
Date: Fri, 3 Apr 2026 15:16:15 +0800
Subject: [PATCH 09/13] fix(platform): move model selector to right side next
 to agent selector

Place both Agent and Model selectors together in the bottom-right of the
chat input, keeping the attach button alone on the left. This matches the
intended layout where agent and model selection are visually grouped.
---
 .../features/chat/components/chat-input.tsx   | 28 +++++++++----------
 .../chat/components/model-selector.tsx        |  1 +
 2 files changed, 14 insertions(+), 15 deletions(-)

diff --git a/services/platform/app/features/chat/components/chat-input.tsx b/services/platform/app/features/chat/components/chat-input.tsx
index cf6941087e..96eb806733 100644
--- a/services/platform/app/features/chat/components/chat-input.tsx
+++ b/services/platform/app/features/chat/components/chat-input.tsx
@@ -302,22 +302,20 @@ export function ChatInput({
           </div>
 
           <HStack justify="between" align="center" className="pb-3">
-            <HStack gap={2} align="center">
-              <Tooltip content={tDialogs('attach')} side="top">
-                <Button
-                  variant="ghost"
-                  size="icon"
-                  onClick={() => fileInputRef.current?.click()}
-                  disabled={inputDisabled}
-                  aria-label={tDialogs('attach')}
-                >
-                  <Paperclip className="size-4" />
-                </Button>
-              </Tooltip>
-              <ModelSelector organizationId={organizationId} />
-            </HStack>
-            <HStack gap={2} align="center">
+            <Tooltip content={tDialogs('attach')} side="top">
+              <Button
+                variant="ghost"
+                size="icon"
+                onClick={() => fileInputRef.current?.click()}
+                disabled={inputDisabled}
+                aria-label={tDialogs('attach')}
+              >
+                <Paperclip className="size-4" />
+              </Button>
+            </Tooltip>
+            <HStack gap={3} align="center">
               <AgentSelector organizationId={organizationId} />
+              <ModelSelector organizationId={organizationId} />
               <Button
                 type="button"
                 onClick={isLoading ? onStopGenerating : handleSendMessage}
diff --git a/services/platform/app/features/chat/components/model-selector.tsx b/services/platform/app/features/chat/components/model-selector.tsx
index d16efc4b72..489f2770f6 100644
--- a/services/platform/app/features/chat/components/model-selector.tsx
+++ b/services/platform/app/features/chat/components/model-selector.tsx
@@ -90,6 +90,7 @@ export function ModelSelector({ organizationId }: ModelSelectorProps) {
 
   const currentLabel = getDisplayName(currentModelId);
 
+  // Single model — show as read-only text
   if (supportedModels.length <= 1) {
     return (
       <span className="text-muted-foreground flex items-center gap-1.5 text-xs">

From 2b25d5e946b833bb6d0818f036ba0d7236690470 Mon Sep 17 00:00:00 2001
From: larryro <371767072@qq.com>
Date: Fri, 3 Apr 2026 16:11:27 +0800
Subject: [PATCH 10/13] refactor(platform): centralize provider model
 resolution and harden provider config
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Extract resolve_model.ts to eliminate repeated resolve→create→getModel
  boilerplate across conversations, agent_chat, summarization, workflows,
  image analysis, and attachments
- Delete agent_runtime_config.ts and openai_provider.ts (superseded by
  file-based provider resolution)
- Remove hardcoded "provider" field from example agent JSON files
- Replace v.any() return types on resolveModelData/resolveModelByTag with
  typed object validators
- Add client-side validation and error toasts to provider edit UI
- Internationalize provider tag labels and error messages
- Add unique model ID and single-default refinements to provider schema
- Python config: add get_chat_config/get_embedding_config/get_vision_config
  with provider-file-first, env-var-fallback resolution
- CLI: include provider configs in init/update checksums, fix ensureEnv
  destructuring
- Docker entrypoint: skip re-seeding providers with user .history, skip
  .secrets.json during seed
- Improve error logging in provider file loading (no silent catches)
---
 examples/agents/chat-agent.json               |  1 -
 examples/agents/crm-assistant.json            |  1 -
 examples/agents/file-assistant.json           |  1 -
 examples/agents/integration-assistant.json    |  1 -
 examples/agents/web-assistant.json            |  1 -
 examples/agents/workflow-assistant.json       |  1 -
 .../src/tale_shared/config/base.py            | 46 ++++++++-
 .../src/tale_shared/config/providers.py       |  9 +-
 .../components/provider-edit-panel.tsx        | 37 ++++++--
 .../hooks/use-providers-table-config.tsx      |  4 +-
 .../$id/settings/providers/$providerName.tsx  | 57 +++++++++---
 services/platform/convex/_generated/api.d.ts  |  6 +-
 .../delegation/load_delegation_agents.ts      |  7 +-
 .../files/helpers/analyze_image.ts            | 24 +----
 .../files/helpers/analyze_image_by_url.ts     | 24 +----
 .../agent_tools/human_input/mutations.ts      |  1 -
 .../convex/agent_tools/location/mutations.ts  |  1 -
 services/platform/convex/agents/config.ts     |  1 -
 .../convex/agents/translate_fields.ts         | 23 +----
 .../platform/convex/conversations/actions.ts  | 22 +----
 services/platform/convex/lib/age_keygen.ts    |  1 +
 .../convex/lib/agent_chat/internal_actions.ts | 53 +++--------
 .../platform/convex/lib/agent_chat/types.ts   |  2 -
 .../convex/lib/agent_runtime_config.ts        | 45 ---------
 .../lib/attachments/process_attachments.ts    | 24 +----
 .../convex/lib/create_agent_config.ts         |  4 +-
 .../platform/convex/lib/openai_provider.ts    | 62 -------------
 services/platform/convex/lib/sops.ts          | 14 +--
 .../lib/summarization/internal_actions.ts     | 22 +----
 .../platform/convex/providers/file_actions.ts | 93 ++++++++++++-------
 .../platform/convex/providers/file_utils.ts   | 10 +-
 .../convex/providers/resolve_model.ts         | 72 ++++++++++++++
 .../platform/convex/providers/validators.ts   |  2 +-
 .../helpers/nodes/llm/execute_llm_node.ts     | 24 +----
 .../utils/validate_and_normalize_config.ts    |  6 +-
 .../helpers/validation/steps/llm.ts           |  2 +-
 .../convex/workflow_engine/types/nodes.ts     |  6 +-
 .../convex/workflows/triggers/actions.ts      | 23 +----
 services/platform/docker-entrypoint.sh        |  6 ++
 .../platform/lib/shared/schemas/providers.ts  | 11 ++-
 services/platform/messages/en.json            | 10 +-
 tools/cli/src/commands/reset.ts               |  2 +-
 tools/cli/src/commands/rollback.ts            |  2 +-
 tools/cli/src/lib/actions/init.ts             | 25 ++---
 tools/cli/src/lib/actions/update.ts           |  5 +
 tools/cli/src/lib/crypto/age-keygen.ts        |  1 +
 46 files changed, 373 insertions(+), 422 deletions(-)
 delete mode 100644 services/platform/convex/lib/agent_runtime_config.ts
 delete mode 100644 services/platform/convex/lib/openai_provider.ts
 create mode 100644 services/platform/convex/providers/resolve_model.ts

diff --git a/examples/agents/chat-agent.json b/examples/agents/chat-agent.json
index b5f5405e73..c8a45a219e 100644
--- a/examples/agents/chat-agent.json
+++ b/examples/agents/chat-agent.json
@@ -18,7 +18,6 @@
     "document_write"
   ],
   "supportedModels": ["moonshotai/kimi-k2.5", "deepseek/deepseek-v3.2"],
-  "provider": "openrouter",
   "knowledgeMode": "tool",
   "includeOrgKnowledge": true,
   "structuredResponsesEnabled": true,
diff --git a/examples/agents/crm-assistant.json b/examples/agents/crm-assistant.json
index 46c731b634..4f33a7a463 100644
--- a/examples/agents/crm-assistant.json
+++ b/examples/agents/crm-assistant.json
@@ -3,7 +3,6 @@
   "displayName": "Sales Assistant",
   "maxSteps": 10,
   "supportedModels": ["anthropic/claude-opus-4.6", "openai/gpt-5.2"],
-  "provider": "openrouter",
   "outputReserve": 2048,
   "structuredResponsesEnabled": false,
   "systemInstructions": "You are a CRM assistant specialized in retrieving customer and product data.\n\n**KNOWLEDGE SCOPE**\nYou access ONLY the organization's internal customer and product database.\nFor data from external systems (Shopify, Salesforce, PMS, etc.), the user needs the Integration Assistant — integrations are configured in [Settings > Integrations]({{site_url}}/dashboard/{{organization.id}}/settings/integrations).\n\n**AVAILABLE TOOLS**\n- customer_read: Read customer data (get_by_id, get_by_email, list operations)\n- product_read: Read product data (get_by_id, list operations)\n\n**ACTION-FIRST PRINCIPLE**\nSearch first, but STOP and ask when multiple matches are found.\n\nALWAYS search first:\n• User mentions a name → search by name/email, don't ask for ID\n• User says \"the customer\" → check conversation context for who they mean\n• Partial info given → use it to search, then proceed\n\n**CRITICAL - MULTIPLE MATCHES:**\nWhen you find 2 or more matching records and the user's request implies ONE specific target:\n1. DO NOT pick one arbitrarily or proceed with all\n2. Return the list of matches with minimal distinguishing details (name, status, source) to help the user identify the correct record\n3. Ask the user to clarify which one they mean\n\nDo NOT ask:\n• For IDs when you have a name to search\n• About scope for simple queries (just return results)\n• For confirmation on obvious single-match requests\n\n**CUSTOMER OPERATIONS**\n- get_by_id: Use when you have a specific customer ID\n- get_by_email: Use when searching by email address\n- list: Use for browsing, filtering, or bulk operations\n\n**PRODUCT OPERATIONS**\n- get_by_id: Use when you have a specific product ID\n- list: Use for browsing the catalog or bulk operations\n\n**BEST PRACTICES**\n1. ALWAYS specify the 'fields' parameter to minimize response size\n2. Avoid 'metadata' field unless specifically requested - it can be very large\n3. Use pagination (cursor) for large datasets instead of fetching all at once\n4. Default numItems is 200; reduce if selecting many fields\n5. If hasMore is true, continue with the returned cursor to fetch more data\n\n**FIELD SELECTION GUIDE**\nCustomer common fields: _id, name, email, status, source, locale\nProduct common fields: _id, name, description, price, currency, status, category\n\nHeavy fields (avoid unless needed):\n- Customer: metadata, address\n- Product: metadata, translations\n\n**RESPONSE GUIDELINES**\n- Present data in clear, structured format (tables for lists)\n- Include pagination info when relevant (hasMore, cursor)\n- Summarize large datasets rather than dumping raw data\n- If data not found, say so clearly\n- Never expose internal IDs unless specifically requested",
diff --git a/examples/agents/file-assistant.json b/examples/agents/file-assistant.json
index 2fceff396f..d3ba4f186c 100644
--- a/examples/agents/file-assistant.json
+++ b/examples/agents/file-assistant.json
@@ -16,7 +16,6 @@
     "request_user_location"
   ],
   "supportedModels": ["qwen/qwen3-next-80b-a3b-instruct", "qwen/qwen3.5-35b-a3b"],
-  "provider": "openrouter",
   "knowledgeMode": "tool",
   "includeTeamKnowledge": false,
   "maxSteps": 15,
diff --git a/examples/agents/integration-assistant.json b/examples/agents/integration-assistant.json
index 3d2fa0a5d4..2b1e6b7354 100644
--- a/examples/agents/integration-assistant.json
+++ b/examples/agents/integration-assistant.json
@@ -7,6 +7,5 @@
   "timeoutMs": 180000,
   "outputReserve": 2048,
   "supportedModels": ["qwen/qwen3-next-80b-a3b-instruct", "qwen/qwen3.5-35b-a3b"],
-  "provider": "openrouter",
   "roleRestriction": "admin_developer"
 }
diff --git a/examples/agents/web-assistant.json b/examples/agents/web-assistant.json
index 073819e125..7a8e4bfad3 100644
--- a/examples/agents/web-assistant.json
+++ b/examples/agents/web-assistant.json
@@ -6,7 +6,6 @@
     "web"
   ],
   "supportedModels": ["qwen/qwen3-next-80b-a3b-instruct", "qwen/qwen3.5-35b-a3b"],
-  "provider": "openrouter",
   "structuredResponsesEnabled": false,
   "maxSteps": 5,
   "timeoutMs": 300000,
diff --git a/examples/agents/workflow-assistant.json b/examples/agents/workflow-assistant.json
index 8788636d25..20f219478b 100644
--- a/examples/agents/workflow-assistant.json
+++ b/examples/agents/workflow-assistant.json
@@ -13,7 +13,6 @@
     "run_workflow"
   ],
   "supportedModels": ["anthropic/claude-opus-4.6", "openai/gpt-5.2"],
-  "provider": "openrouter",
   "maxSteps": 30,
   "timeoutMs": 240000,
   "outputReserve": 2048,
diff --git a/packages/tale_shared/src/tale_shared/config/base.py b/packages/tale_shared/src/tale_shared/config/base.py
index e1697422c3..4300ea8b30 100644
--- a/packages/tale_shared/src/tale_shared/config/base.py
+++ b/packages/tale_shared/src/tale_shared/config/base.py
@@ -99,8 +99,52 @@ def get_vision_model(self) -> str:
             raise ValueError("OPENAI_VISION_MODEL must be set in environment.")
         return model
 
+    def get_chat_config(self) -> tuple[str, str, str]:
+        """Return (base_url, api_key, model_id) for chat model from provider files, then env var fallback."""
+        try:
+            return _provider_chat_model()
+        except (ValueError, FileNotFoundError):
+            logger.debug("No provider chat config found, falling back to env vars")
+            return (
+                self.get_openai_base_url(),
+                self.get_openai_api_key(),
+                self.get_fast_model(),
+            )
+
+    def get_embedding_config(self) -> tuple[str, str, str, int]:
+        """Return (base_url, api_key, model_id, dimensions) for embedding model."""
+        try:
+            return _provider_embedding_model()
+        except (ValueError, FileNotFoundError):
+            logger.debug("No provider embedding config found, falling back to env vars")
+            return (
+                self.get_openai_base_url(),
+                self.get_openai_api_key(),
+                self.get_embedding_model(),
+                self.get_embedding_dimensions(),
+            )
+
+    def get_vision_config(self) -> tuple[str, str, str]:
+        """Return (base_url, api_key, model_id) for vision model."""
+        try:
+            return _provider_vision_model()
+        except (ValueError, FileNotFoundError):
+            logger.debug("No provider vision config found, falling back to env vars")
+            return (
+                self.get_openai_base_url(),
+                self.get_openai_api_key(),
+                self.get_vision_model(),
+            )
+
     def get_embedding_dimensions(self) -> int:
-        """Get embedding dimensions from service-prefixed or generic env var."""
+        """Get embedding dimensions from provider files, then env var fallback."""
+        try:
+            _base_url, _api_key, _model_id, dims = _provider_embedding_model()
+            return dims
+        except (ValueError, FileNotFoundError):
+            logger.debug(
+                "No provider embedding dimensions found, falling back to env vars"
+            )
         dims = self.embedding_dimensions
         if dims is None:
             raw = os.environ.get("EMBEDDING_DIMENSIONS")
diff --git a/packages/tale_shared/src/tale_shared/config/providers.py b/packages/tale_shared/src/tale_shared/config/providers.py
index b253ebc9d7..5dd3a446c8 100644
--- a/packages/tale_shared/src/tale_shared/config/providers.py
+++ b/packages/tale_shared/src/tale_shared/config/providers.py
@@ -3,6 +3,7 @@
 import json
 import logging
 import os
+import subprocess
 from dataclasses import dataclass, field
 from pathlib import Path
 
@@ -44,7 +45,11 @@ def load_providers(config_dir: str | None = None) -> list[ProviderConfig]:
     Reads *.json (excluding *.secrets.json) and decrypts matching
     *.secrets.json files via SOPS.
     """
-    base = Path(config_dir or os.environ.get("CONFIG_DIR", DEFAULT_CONFIG_DIR))
+    base = Path(
+        config_dir
+        or os.environ.get("TALE_CONFIG_DIR")
+        or os.environ.get("CONFIG_DIR", DEFAULT_CONFIG_DIR)
+    )
     providers_dir = base / "providers"
 
     if not providers_dir.is_dir():
@@ -73,7 +78,7 @@ def load_providers(config_dir: str | None = None) -> list[ProviderConfig]:
             try:
                 secrets = decrypt_secrets_file(secrets_file)
                 api_key = secrets.get("apiKey")
-            except (RuntimeError, OSError) as exc:
+            except (RuntimeError, OSError, subprocess.TimeoutExpired) as exc:
                 logger.error("Failed to decrypt secrets for %s: %s", provider_name, exc)
 
         models = []
diff --git a/services/platform/app/features/settings/providers/components/provider-edit-panel.tsx b/services/platform/app/features/settings/providers/components/provider-edit-panel.tsx
index 05ab6342bd..afaf296700 100644
--- a/services/platform/app/features/settings/providers/components/provider-edit-panel.tsx
+++ b/services/platform/app/features/settings/providers/components/provider-edit-panel.tsx
@@ -43,11 +43,7 @@ interface ModelFormData {
   dimensions: string;
 }
 
-const TAG_OPTIONS = [
-  { value: 'chat', label: 'Chat' },
-  { value: 'vision', label: 'Vision' },
-  { value: 'embedding', label: 'Embedding' },
-];
+const TAG_VALUES = ['chat', 'vision', 'embedding'] as const;
 
 function emptyModel(): ModelFormData {
   return {
@@ -122,6 +118,25 @@ export function ProviderEditPanel({
   }, [readResult]);
 
   const handleSave = useCallback(async () => {
+    // Validate before saving
+    if (!displayName.trim()) {
+      toast({
+        title: t('providers.displayNameRequired'),
+        variant: 'destructive',
+      });
+      return;
+    }
+    try {
+      const _url = new URL(baseUrl);
+      void _url;
+    } catch {
+      toast({ title: t('providers.invalidBaseUrl'), variant: 'destructive' });
+      return;
+    }
+    if (models.some((m) => !m.id.trim())) {
+      toast({ title: t('providers.modelIdRequired'), variant: 'destructive' });
+      return;
+    }
     try {
       const config = {
         displayName,
@@ -348,7 +363,17 @@ export function ProviderEditPanel({
                   />
                   <CheckboxGroup
                     label={t('providers.tags')}
-                    options={TAG_OPTIONS}
+                    options={TAG_VALUES.map((tag) => ({
+                      value: tag,
+                      label:
+                        tag === 'chat'
+                          ? t('providers.tagChat')
+                          : tag === 'vision'
+                            ? t('providers.tagVision')
+                            : tag === 'embedding'
+                              ? t('providers.tagEmbedding')
+                              : tag,
+                    }))}
                     value={model.tags}
                     onValueChange={(tags) => updateModel(index, { tags })}
                     columns={2}
diff --git a/services/platform/app/features/settings/providers/hooks/use-providers-table-config.tsx b/services/platform/app/features/settings/providers/hooks/use-providers-table-config.tsx
index 6a76072fb5..c51b1def85 100644
--- a/services/platform/app/features/settings/providers/hooks/use-providers-table-config.tsx
+++ b/services/platform/app/features/settings/providers/hooks/use-providers-table-config.tsx
@@ -49,7 +49,9 @@ export function useProvidersTableConfig(_opts: {
         header: t('providers.models'),
         meta: { skeleton: { type: 'badge' } },
         cell: ({ row }) => (
-          <Badge variant="outline">{row.original.modelCount ?? 0} models</Badge>
+          <Badge variant="outline">
+            {t('providers.modelCount', { count: row.original.modelCount ?? 0 })}
+          </Badge>
         ),
         size: 120,
       },
diff --git a/services/platform/app/routes/dashboard/$id/settings/providers/$providerName.tsx b/services/platform/app/routes/dashboard/$id/settings/providers/$providerName.tsx
index 30ac78f61e..6ceeca6fb3 100644
--- a/services/platform/app/routes/dashboard/$id/settings/providers/$providerName.tsx
+++ b/services/platform/app/routes/dashboard/$id/settings/providers/$providerName.tsx
@@ -33,6 +33,7 @@ import {
   ProviderConfigProvider,
   useProviderConfig,
 } from '@/app/features/settings/providers/hooks/use-provider-config-context';
+import { toast } from '@/app/hooks/use-toast';
 import { useT } from '@/lib/i18n/client';
 
 export const Route = createFileRoute(
@@ -42,6 +43,7 @@ export const Route = createFileRoute(
 });
 
 function ProviderDetailRoute() {
+  const { t } = useT('settings');
   const { id: organizationId, providerName } = Route.useParams();
   const { data, isLoading } = useReadProvider('default', providerName);
 
@@ -57,12 +59,14 @@ function ProviderDetailRoute() {
   if (!data?.ok) {
     return (
       <Stack gap={4} className="p-6">
-        <Text variant="muted">Provider not found: {providerName}</Text>
+        <Text variant="muted">
+          {t('providers.providerNotFound', { name: providerName })}
+        </Text>
         <Link
           to="/dashboard/$id/settings/providers"
           params={{ id: organizationId }}
         >
-          <Button variant="secondary">Back to providers</Button>
+          <Button variant="secondary">{t('providers.backToProviders')}</Button>
         </Link>
       </Stack>
     );
@@ -103,21 +107,27 @@ function ProviderDetailContent({
         providerName,
         config,
       });
+    } catch {
+      toast({ title: t('providers.saveFailed'), variant: 'destructive' });
     } finally {
       markSaving(false);
     }
-  }, [config, providerName, saveProvider, markSaving]);
+  }, [config, providerName, saveProvider, markSaving, t]);
 
   const handleDelete = useCallback(async () => {
-    await deleteProvider.mutateAsync({
-      orgSlug: 'default',
-      providerName,
-    });
-    void navigate({
-      to: '/dashboard/$id/settings/providers',
-      params: { id: organizationId },
-    });
-  }, [providerName, organizationId, deleteProvider, navigate]);
+    try {
+      await deleteProvider.mutateAsync({
+        orgSlug: 'default',
+        providerName,
+      });
+      void navigate({
+        to: '/dashboard/$id/settings/providers',
+        params: { id: organizationId },
+      });
+    } catch {
+      toast({ title: t('providers.deleteFailed'), variant: 'destructive' });
+    }
+  }, [providerName, organizationId, deleteProvider, navigate, t]);
 
   return (
     <Stack gap={6} className="p-6">
@@ -301,11 +311,16 @@ function ApiKeySection({ providerName }: { providerName: string }) {
         });
         setApiKey('');
         setDialogOpen(false);
+      } catch {
+        toast({
+          title: t('providers.secretSaveFailed'),
+          variant: 'destructive',
+        });
       } finally {
         setSaving(false);
       }
     },
-    [apiKey, providerName, saveSecret],
+    [apiKey, providerName, saveSecret, t],
   );
 
   return (
@@ -514,7 +529,13 @@ function ModelsSection() {
                   <HStack gap={1}>
                     {model.tags.map((tag) => (
                       <Badge key={tag} variant="outline" className="text-xs">
-                        {tag}
+                        {tag === 'chat'
+                          ? t('providers.tagChat')
+                          : tag === 'vision'
+                            ? t('providers.tagVision')
+                            : tag === 'embedding'
+                              ? t('providers.tagEmbedding')
+                              : tag}
                       </Badge>
                     ))}
                   </HStack>
@@ -596,7 +617,13 @@ function ModelsSection() {
                     }));
                   }}
                 />
-                {tag}
+                {tag === 'chat'
+                  ? t('providers.tagChat')
+                  : tag === 'vision'
+                    ? t('providers.tagVision')
+                    : tag === 'embedding'
+                      ? t('providers.tagEmbedding')
+                      : tag}
               </label>
             ))}
           </HStack>
diff --git a/services/platform/convex/_generated/api.d.ts b/services/platform/convex/_generated/api.d.ts
index 1641e247be..30deb67cf1 100644
--- a/services/platform/convex/_generated/api.d.ts
+++ b/services/platform/convex/_generated/api.d.ts
@@ -322,7 +322,6 @@ import type * as lib_agent_response_structured_response_instructions from "../li
 import type * as lib_agent_response_types from "../lib/agent_response/types.js";
 import type * as lib_agent_response_validators from "../lib/agent_response/validators.js";
 import type * as lib_agent_response_with_timeout from "../lib/agent_response/with_timeout.js";
-import type * as lib_agent_runtime_config from "../lib/agent_runtime_config.js";
 import type * as lib_attachments_build_multi_modal_content from "../lib/attachments/build_multi_modal_content.js";
 import type * as lib_attachments_format_markdown from "../lib/attachments/format_markdown.js";
 import type * as lib_attachments_index from "../lib/attachments/index.js";
@@ -363,7 +362,6 @@ import type * as lib_helpers_rag_config from "../lib/helpers/rag_config.js";
 import type * as lib_helpers_status_priority from "../lib/helpers/status_priority.js";
 import type * as lib_message_deduplication from "../lib/message_deduplication.js";
 import type * as lib_metadata_get_metadata_string from "../lib/metadata/get_metadata_string.js";
-import type * as lib_openai_provider from "../lib/openai_provider.js";
 import type * as lib_pagination_helpers from "../lib/pagination/helpers.js";
 import type * as lib_pagination_index from "../lib/pagination/index.js";
 import type * as lib_pagination_types from "../lib/pagination/types.js";
@@ -507,6 +505,7 @@ import type * as products_upsert_product_translation from "../products/upsert_pr
 import type * as products_validators from "../products/validators.js";
 import type * as providers_file_actions from "../providers/file_actions.js";
 import type * as providers_file_utils from "../providers/file_utils.js";
+import type * as providers_resolve_model from "../providers/resolve_model.js";
 import type * as providers_validators from "../providers/validators.js";
 import type * as sso_providers_actions from "../sso_providers/actions.js";
 import type * as sso_providers_create_user_session from "../sso_providers/create_user_session.js";
@@ -1159,7 +1158,6 @@ declare const fullApi: ApiFromModules<{
   "lib/agent_response/types": typeof lib_agent_response_types;
   "lib/agent_response/validators": typeof lib_agent_response_validators;
   "lib/agent_response/with_timeout": typeof lib_agent_response_with_timeout;
-  "lib/agent_runtime_config": typeof lib_agent_runtime_config;
   "lib/attachments/build_multi_modal_content": typeof lib_attachments_build_multi_modal_content;
   "lib/attachments/format_markdown": typeof lib_attachments_format_markdown;
   "lib/attachments/index": typeof lib_attachments_index;
@@ -1200,7 +1198,6 @@ declare const fullApi: ApiFromModules<{
   "lib/helpers/status_priority": typeof lib_helpers_status_priority;
   "lib/message_deduplication": typeof lib_message_deduplication;
   "lib/metadata/get_metadata_string": typeof lib_metadata_get_metadata_string;
-  "lib/openai_provider": typeof lib_openai_provider;
   "lib/pagination/helpers": typeof lib_pagination_helpers;
   "lib/pagination/index": typeof lib_pagination_index;
   "lib/pagination/types": typeof lib_pagination_types;
@@ -1344,6 +1341,7 @@ declare const fullApi: ApiFromModules<{
   "products/validators": typeof products_validators;
   "providers/file_actions": typeof providers_file_actions;
   "providers/file_utils": typeof providers_file_utils;
+  "providers/resolve_model": typeof providers_resolve_model;
   "providers/validators": typeof providers_validators;
   "sso_providers/actions": typeof sso_providers_actions;
   "sso_providers/create_user_session": typeof sso_providers_create_user_session;
diff --git a/services/platform/convex/agent_tools/delegation/load_delegation_agents.ts b/services/platform/convex/agent_tools/delegation/load_delegation_agents.ts
index c987fa7ae5..32059f35b3 100644
--- a/services/platform/convex/agent_tools/delegation/load_delegation_agents.ts
+++ b/services/platform/convex/agent_tools/delegation/load_delegation_agents.ts
@@ -49,8 +49,11 @@ export async function loadDelegateAgents(
         provider: agentConfig.provider,
         roleRestriction: config.roleRestriction,
       });
-    } catch {
-      // Skip unavailable delegate agents gracefully
+    } catch (err) {
+      console.warn(
+        `Delegate agent "${name}" unavailable, skipping.`,
+        err instanceof Error ? err.message : err,
+      );
     }
   }
 
diff --git a/services/platform/convex/agent_tools/files/helpers/analyze_image.ts b/services/platform/convex/agent_tools/files/helpers/analyze_image.ts
index d8aeea0cc6..647137c38b 100644
--- a/services/platform/convex/agent_tools/files/helpers/analyze_image.ts
+++ b/services/platform/convex/agent_tools/files/helpers/analyze_image.ts
@@ -10,14 +10,13 @@
  * This file runs in V8 runtime (no 'use node' directive) for better compatibility.
  */
 
-import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
-
 import type { Id } from '../../../_generated/dataModel';
 import type { ActionCtx } from '../../../_generated/server';
 
-import { components, internal } from '../../../_generated/api';
+import { components } from '../../../_generated/api';
 import { imageAnalysisCache } from '../../../lib/action_cache';
 import { createDebugLog } from '../../../lib/debug_log';
+import { resolveLanguageModel } from '../../../providers/resolve_model';
 import { createVisionAgent } from './vision_agent';
 
 const debugLog = createDebugLog('DEBUG_IMAGE_ANALYSIS', '[ImageAnalysis]');
@@ -55,24 +54,9 @@ export async function analyzeImage(
   debugLog('analyzeImage starting', { fileId, question, fileName });
 
   // Resolve vision model from provider files
-  // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- resolveModelByTag returns v.any() but shape is guaranteed by provider file_actions contract
-  const modelData = (await ctx.runAction(
-    internal.providers.file_actions.resolveModelByTag,
-    { tag: 'vision' },
-  )) as {
-    providerName: string;
-    baseUrl: string;
-    apiKey: string;
-    modelId: string;
-    supportsStructuredOutputs: boolean;
-  };
-  const providerInstance = createOpenAICompatible({
-    name: modelData.providerName,
-    baseURL: modelData.baseUrl,
-    apiKey: modelData.apiKey,
-    supportsStructuredOutputs: modelData.supportsStructuredOutputs,
+  const { languageModel, modelData } = await resolveLanguageModel(ctx, {
+    tag: 'vision',
   });
-  const languageModel = providerInstance.chatModel(modelData.modelId);
   const visionModelId = modelData.modelId;
 
   try {
diff --git a/services/platform/convex/agent_tools/files/helpers/analyze_image_by_url.ts b/services/platform/convex/agent_tools/files/helpers/analyze_image_by_url.ts
index 03d1a03f3a..dc5373394d 100644
--- a/services/platform/convex/agent_tools/files/helpers/analyze_image_by_url.ts
+++ b/services/platform/convex/agent_tools/files/helpers/analyze_image_by_url.ts
@@ -4,13 +4,12 @@
  * Helper for analyzing images by URL using the vision model.
  */
 
-import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
-
 import type { ActionCtx } from '../../../_generated/server';
 import type { AnalyzeImageResult } from './analyze_image';
 
-import { components, internal } from '../../../_generated/api';
+import { components } from '../../../_generated/api';
 import { createDebugLog } from '../../../lib/debug_log';
+import { resolveLanguageModel } from '../../../providers/resolve_model';
 import { createVisionAgent } from './vision_agent';
 
 const debugLog = createDebugLog('DEBUG_IMAGE_ANALYSIS', '[ImageAnalysis]');
@@ -39,24 +38,9 @@ export async function analyzeImageByUrl(
   });
 
   // Resolve vision model from provider files
-  // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- resolveModelByTag returns v.any() but shape is guaranteed by provider file_actions contract
-  const modelData = (await ctx.runAction(
-    internal.providers.file_actions.resolveModelByTag,
-    { tag: 'vision' },
-  )) as {
-    providerName: string;
-    baseUrl: string;
-    apiKey: string;
-    modelId: string;
-    supportsStructuredOutputs: boolean;
-  };
-  const providerInstance = createOpenAICompatible({
-    name: modelData.providerName,
-    baseURL: modelData.baseUrl,
-    apiKey: modelData.apiKey,
-    supportsStructuredOutputs: modelData.supportsStructuredOutputs,
+  const { languageModel, modelData } = await resolveLanguageModel(ctx, {
+    tag: 'vision',
   });
-  const languageModel = providerInstance.chatModel(modelData.modelId);
   const visionModelId = modelData.modelId;
 
   try {
diff --git a/services/platform/convex/agent_tools/human_input/mutations.ts b/services/platform/convex/agent_tools/human_input/mutations.ts
index 02920a5249..22a39bd7e0 100644
--- a/services/platform/convex/agent_tools/human_input/mutations.ts
+++ b/services/platform/convex/agent_tools/human_input/mutations.ts
@@ -190,7 +190,6 @@ export const submitHumanInputResponse = mutation({
       instructions: '',
       convexToolNames: [],
       model: 'default',
-      enableVectorSearch: false,
       knowledgeMode: 'off' as const,
       webSearchMode: 'off' as const,
       includeTeamKnowledge: false,
diff --git a/services/platform/convex/agent_tools/location/mutations.ts b/services/platform/convex/agent_tools/location/mutations.ts
index 4188e5ac75..e2ee11e8ad 100644
--- a/services/platform/convex/agent_tools/location/mutations.ts
+++ b/services/platform/convex/agent_tools/location/mutations.ts
@@ -153,7 +153,6 @@ export const submitLocationResponse = mutation({
       instructions: '',
       convexToolNames: [],
       model: 'default',
-      enableVectorSearch: false,
       knowledgeMode: 'off' as const,
       webSearchMode: 'off' as const,
       includeTeamKnowledge: false,
diff --git a/services/platform/convex/agents/config.ts b/services/platform/convex/agents/config.ts
index 9e019aaf25..d538ec6a29 100644
--- a/services/platform/convex/agents/config.ts
+++ b/services/platform/convex/agents/config.ts
@@ -34,7 +34,6 @@ export function toSerializableConfig(
       })(),
     provider: config.provider,
     maxSteps: config.maxSteps,
-    enableVectorSearch: false,
     knowledgeMode,
     webSearchMode,
     includeTeamKnowledge: config.includeTeamKnowledge ?? true,
diff --git a/services/platform/convex/agents/translate_fields.ts b/services/platform/convex/agents/translate_fields.ts
index 4a7e9a0ce6..cc69723991 100644
--- a/services/platform/convex/agents/translate_fields.ts
+++ b/services/platform/convex/agents/translate_fields.ts
@@ -1,12 +1,12 @@
 'use node';
 
-import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
 import { Agent } from '@convex-dev/agent';
 import { z } from 'zod';
 
 import type { ActionCtx } from '../_generated/server';
 
-import { components, internal } from '../_generated/api';
+import { components } from '../_generated/api';
+import { resolveLanguageModel } from '../providers/resolve_model';
 
 const MAX_RETRIES = 3;
 
@@ -104,24 +104,7 @@ export async function translateFields(
   });
 
   // Resolve chat model from provider files
-  // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- resolveModelByTag returns v.any() but shape is guaranteed by provider file_actions contract
-  const modelData = (await ctx.runAction(
-    internal.providers.file_actions.resolveModelByTag,
-    { tag: 'chat' },
-  )) as {
-    providerName: string;
-    baseUrl: string;
-    apiKey: string;
-    modelId: string;
-    supportsStructuredOutputs: boolean;
-  };
-  const providerInstance = createOpenAICompatible({
-    name: modelData.providerName,
-    baseURL: modelData.baseUrl,
-    apiKey: modelData.apiKey,
-    supportsStructuredOutputs: modelData.supportsStructuredOutputs,
-  });
-  const languageModel = providerInstance.chatModel(modelData.modelId);
+  const { languageModel } = await resolveLanguageModel(ctx, { tag: 'chat' });
 
   const agent = createTranslationAgent(args.targetLocale, languageModel);
   const userId = `translate-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
diff --git a/services/platform/convex/conversations/actions.ts b/services/platform/convex/conversations/actions.ts
index 4d17378e2d..7da9c8544f 100644
--- a/services/platform/convex/conversations/actions.ts
+++ b/services/platform/convex/conversations/actions.ts
@@ -1,9 +1,8 @@
-import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
 import { v } from 'convex/values';
 
-import { internal } from '../_generated/api';
 import { action } from '../_generated/server';
 import { authComponent } from '../auth';
+import { resolveLanguageModel } from '../providers/resolve_model';
 import { improveMessage as improveMessageHandler } from './improve_message';
 
 export const improveMessage = action({
@@ -25,24 +24,7 @@ export const improveMessage = action({
     }
 
     // Resolve fast/chat model from provider files
-    // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- resolveModelByTag returns v.any() but shape is guaranteed by provider file_actions contract
-    const modelData = (await ctx.runAction(
-      internal.providers.file_actions.resolveModelByTag,
-      { tag: 'chat' },
-    )) as {
-      providerName: string;
-      baseUrl: string;
-      apiKey: string;
-      modelId: string;
-      supportsStructuredOutputs: boolean;
-    };
-    const provider = createOpenAICompatible({
-      name: modelData.providerName,
-      baseURL: modelData.baseUrl,
-      apiKey: modelData.apiKey,
-      supportsStructuredOutputs: modelData.supportsStructuredOutputs,
-    });
-    const languageModel = provider.chatModel(modelData.modelId);
+    const { languageModel } = await resolveLanguageModel(ctx, { tag: 'chat' });
 
     return improveMessageHandler(ctx, {
       ...args,
diff --git a/services/platform/convex/lib/age_keygen.ts b/services/platform/convex/lib/age_keygen.ts
index adeeb0c141..d4a4b8d113 100644
--- a/services/platform/convex/lib/age_keygen.ts
+++ b/services/platform/convex/lib/age_keygen.ts
@@ -24,6 +24,7 @@ export function deriveAgePublicKey(secretKey: string): string {
   if (!lowercase.includes('1')) {
     throw new Error('Invalid age secret key: missing bech32 separator');
   }
+  // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- The includes('1') guard above proves the bech32 separator exists
   const decoded = bech32.decode(lowercase as `${string}1${string}`, false);
   if (decoded.prefix !== SECRET_HRP) {
     throw new Error(`Invalid age secret key prefix: "${decoded.prefix}"`);
diff --git a/services/platform/convex/lib/agent_chat/internal_actions.ts b/services/platform/convex/lib/agent_chat/internal_actions.ts
index 97c94f66ee..46cb18c9ba 100644
--- a/services/platform/convex/lib/agent_chat/internal_actions.ts
+++ b/services/platform/convex/lib/agent_chat/internal_actions.ts
@@ -2,7 +2,6 @@
 
 import type { FunctionHandle } from 'convex/server';
 
-import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
 import {
   Agent,
   listMessages,
@@ -38,6 +37,10 @@ import {
   sanitizeWorkflowName,
 } from '../../agent_tools/workflows/create_bound_workflow_tool';
 import { extractInputSchema } from '../../agent_tools/workflows/helpers/extract_input_schema';
+import {
+  resolveLanguageModel,
+  resolveLanguageModelById,
+} from '../../providers/resolve_model';
 import { generateAgentResponse } from '../agent_response';
 import {
   estimateTokens,
@@ -66,7 +69,6 @@ const serializableAgentConfigValidator = v.object({
   provider: v.optional(v.string()),
   maxSteps: v.optional(v.number()),
   outputFormat: v.optional(v.union(v.literal('text'), v.literal('json'))),
-  enableVectorSearch: v.optional(v.boolean()),
   knowledgeMode: v.optional(
     v.union(
       v.literal('off'),
@@ -281,56 +283,29 @@ export const runAgentGeneration = internalAction({
 
     // Resolve model from provider files
     const modelId = model === 'default' ? undefined : model;
-    // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- resolveModelByTag/resolveModelData returns v.any() but shape is guaranteed by provider file_actions contract
-    const modelData = modelId
-      ? ((await ctx.runAction(
-          internal.providers.file_actions.resolveModelData,
-          { modelId, providerName: agentConfig.provider },
-        )) as {
-          providerName: string;
-          baseUrl: string;
-          apiKey: string;
-          modelId: string;
-          supportsStructuredOutputs: boolean;
+    const { languageModel } = modelId
+      ? await resolveLanguageModelById(ctx, {
+          modelId,
+          providerName: agentConfig.provider,
         })
-      : ((await ctx.runAction(
-          internal.providers.file_actions.resolveModelByTag,
-          { tag: 'chat', providerName: agentConfig.provider },
-        )) as {
-          providerName: string;
-          baseUrl: string;
-          apiKey: string;
-          modelId: string;
-          supportsStructuredOutputs: boolean;
+      : await resolveLanguageModel(ctx, {
+          tag: 'chat',
+          providerName: agentConfig.provider,
         });
-    const providerInstance = createOpenAICompatible({
-      name: modelData.providerName,
-      baseURL: modelData.baseUrl,
-      apiKey: modelData.apiKey,
-      supportsStructuredOutputs: modelData.supportsStructuredOutputs,
-    });
-    const languageModel = providerInstance.chatModel(modelData.modelId);
 
     // Create agent factory function from serializable config
-    const createAgent = (options?: Record<string, unknown>) => {
-      // If a model override is provided via options, resolve it inline
-      // (this is a sync context, so we use the already-resolved languageModel)
-      const effectiveLanguageModel = languageModel;
-
+    const createAgent = () => {
       const config = createAgentConfig({
         name: agentConfig.name,
         instructions: finalInstructions,
-        languageModel: effectiveLanguageModel,
+        languageModel,
         convexToolNames: agentConfig.convexToolNames
           ? agentConfig.convexToolNames.filter((n): n is ToolName =>
               (TOOL_NAMES as readonly string[]).includes(n),
             )
           : undefined,
         extraTools: allExtraTools,
-        maxSteps:
-          (typeof options?.maxSteps === 'number'
-            ? options.maxSteps
-            : undefined) ?? agentConfig.maxSteps,
+        maxSteps: agentConfig.maxSteps,
         outputFormat: agentConfig.outputFormat,
       });
       return new Agent(components.agent, config);
diff --git a/services/platform/convex/lib/agent_chat/types.ts b/services/platform/convex/lib/agent_chat/types.ts
index 5e7d31b779..3290b67149 100644
--- a/services/platform/convex/lib/agent_chat/types.ts
+++ b/services/platform/convex/lib/agent_chat/types.ts
@@ -31,8 +31,6 @@ export interface SerializableAgentConfig {
   maxSteps?: number;
   /** Output format (text or json) */
   outputFormat?: 'text' | 'json';
-  /** Enable vector search for semantic message retrieval */
-  enableVectorSearch?: boolean;
   /** Knowledge retrieval mode: tool (agent calls rag_search), context (auto-inject), both, or off */
   knowledgeMode?: 'off' | 'tool' | 'context' | 'both';
   /** Web search retrieval mode: tool (agent calls web), context (auto-inject), both, or off */
diff --git a/services/platform/convex/lib/agent_runtime_config.ts b/services/platform/convex/lib/agent_runtime_config.ts
deleted file mode 100644
index 390de95946..0000000000
--- a/services/platform/convex/lib/agent_runtime_config.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import {
-  getFirstModel,
-  getFirstModelOrThrow,
-} from '../../lib/shared/utils/model-list';
-
-/**
- * Get the first standard model from OPENAI_MODEL.
- */
-export function getDefaultModel(): string {
-  return getFirstModelOrThrow(process.env.OPENAI_MODEL, 'OPENAI_MODEL');
-}
-
-/**
- * Get the first fast model from OPENAI_FAST_MODEL.
- */
-export function getFastModel(): string {
-  return getFirstModelOrThrow(
-    process.env.OPENAI_FAST_MODEL,
-    'OPENAI_FAST_MODEL',
-  );
-}
-
-/**
- * Get the first coding/advanced model from OPENAI_CODING_MODEL, or undefined.
- */
-export function getCodingModel(): string | undefined {
-  return getFirstModel(process.env.OPENAI_CODING_MODEL);
-}
-
-/**
- * Get the first coding/advanced model from OPENAI_CODING_MODEL, or throw.
- */
-export function getCodingModelOrThrow(): string {
-  return getFirstModelOrThrow(
-    process.env.OPENAI_CODING_MODEL,
-    'OPENAI_CODING_MODEL',
-  );
-}
-
-export function getDefaultAgentRuntimeConfig() {
-  return {
-    model: getDefaultModel(),
-    provider: 'openai' as const,
-  };
-}
diff --git a/services/platform/convex/lib/attachments/process_attachments.ts b/services/platform/convex/lib/attachments/process_attachments.ts
index d867b1675a..01a575a687 100644
--- a/services/platform/convex/lib/attachments/process_attachments.ts
+++ b/services/platform/convex/lib/attachments/process_attachments.ts
@@ -7,17 +7,15 @@
 
 import type { LanguageModelV3 } from '@ai-sdk/provider';
 
-import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
-
 import type { Id } from '../../_generated/dataModel';
 import type { ActionCtx } from '../../_generated/server';
 import type { FileAttachment, MessageContentPart } from './types';
 
 import { isImage, isTextFile } from '../../../lib/shared/file-types';
-import { internal } from '../../_generated/api';
 import { analyzeImageCached } from '../../agent_tools/files/helpers/analyze_image';
 import { analyzeTextContent } from '../../agent_tools/files/helpers/analyze_text';
 import { parseFile } from '../../agent_tools/files/helpers/parse_file';
+import { resolveLanguageModel } from '../../providers/resolve_model';
 import { registerFilesWithAgent } from './register_files';
 
 /**
@@ -201,24 +199,8 @@ export async function processAttachments(
   // Resolve language model for text analysis if not provided
   let resolvedLanguageModelV3 = config.languageModel;
   if (!resolvedLanguageModelV3 && textFileAttachments.length > 0) {
-    // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- resolveModelByTag returns v.any() but shape is guaranteed by provider file_actions contract
-    const modelData = (await ctx.runAction(
-      internal.providers.file_actions.resolveModelByTag,
-      { tag: 'chat' },
-    )) as {
-      providerName: string;
-      baseUrl: string;
-      apiKey: string;
-      modelId: string;
-      supportsStructuredOutputs: boolean;
-    };
-    const providerInstance = createOpenAICompatible({
-      name: modelData.providerName,
-      baseURL: modelData.baseUrl,
-      apiKey: modelData.apiKey,
-      supportsStructuredOutputs: modelData.supportsStructuredOutputs,
-    });
-    resolvedLanguageModelV3 = providerInstance.chatModel(modelData.modelId);
+    const resolved = await resolveLanguageModel(ctx, { tag: 'chat' });
+    resolvedLanguageModelV3 = resolved.languageModel;
   }
 
   // Analyze text files with LLM (in parallel)
diff --git a/services/platform/convex/lib/create_agent_config.ts b/services/platform/convex/lib/create_agent_config.ts
index 7c69583b08..eebbec1e7e 100644
--- a/services/platform/convex/lib/create_agent_config.ts
+++ b/services/platform/convex/lib/create_agent_config.ts
@@ -22,7 +22,7 @@ export function createAgentConfig(opts: {
   name: string;
   /** Pre-resolved language model instance from the provider */
   languageModel: LanguageModelV3;
-  /** Pre-resolved text embedding model for vector search (pass when enableVectorSearch is true) */
+  /** Pre-resolved text embedding model for vector search */
   textEmbeddingModel?: unknown;
   /** Temperature override. If not provided, auto-determined by outputFormat: json→0.2, text→0.5 */
   temperature?: number;
@@ -158,7 +158,7 @@ Example: User asks for "John's email" and you find 3 Johns:
     languageModel: opts.languageModel,
     callSettings,
     ...(typeof opts.maxTokens === 'number'
-      ? { providerOptions: { openai: { maxOutputTokens: opts.maxTokens } } }
+      ? { maxOutputTokens: opts.maxTokens }
       : {}),
     ...(hasAnyTools ? { tools: mergedTools } : {}),
     ...(typeof effectiveMaxSteps === 'number'
diff --git a/services/platform/convex/lib/openai_provider.ts b/services/platform/convex/lib/openai_provider.ts
deleted file mode 100644
index 509d885eb7..0000000000
--- a/services/platform/convex/lib/openai_provider.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
-
-import { getEnvOrThrow, getEnvOptional } from './get_or_throw';
-
-/**
- * Shared OpenAI-compatible provider for Convex code.
- *
- * This is configured via environment variables so we can point at
- * OpenAI, OpenRouter, or any other OpenAI-compatible endpoint.
- *
- * Uses @ai-sdk/openai-compatible which natively extracts reasoning_content
- * from Chat Completions responses (needed for thinking models like Kimi 2.5,
- * DeepSeek R1, etc. via OpenRouter).
- *
- * - OPENAI_API_KEY: API key for the provider (required)
- * - OPENAI_BASE_URL: optional custom base URL (e.g. https://openrouter.ai/api/v1)
- *
- * The provider is lazily initialized on first access to ensure environment
- * variables are available (important for Convex startup sequence).
- */
-
-type OpenAIProvider = ReturnType<typeof createOpenAICompatible>;
-
-let _instance: OpenAIProvider | null = null;
-
-function getProvider() {
-  if (_instance === null) {
-    const apiKey = getEnvOrThrow(
-      'OPENAI_API_KEY',
-      'API key for OpenAI provider',
-    );
-    const baseURL =
-      getEnvOptional('OPENAI_BASE_URL') ?? 'https://api.openai.com/v1';
-
-    _instance = createOpenAICompatible({
-      name: 'openai',
-      baseURL,
-      apiKey,
-      supportsStructuredOutputs: true,
-    });
-  }
-  return _instance;
-}
-
-// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Proxy target must match OpenAIProvider shape; actual calls are forwarded to the real lazily-initialized provider
-const proxyTarget = Object.assign(
-  function () {},
-  {},
-) as unknown as OpenAIProvider;
-
-export const openai: OpenAIProvider = new Proxy(proxyTarget, {
-  apply(_target, _thisArg, args) {
-    const provider = getProvider();
-    // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- provider is callable (OpenAI-compatible factory); Function type required for .apply()
-    return (provider as unknown as Function).apply(null, args);
-  },
-  get(_target, prop) {
-    const provider = getProvider();
-    // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Proxy get trap receives string|symbol; narrow to known provider keys
-    return provider[prop as keyof typeof provider];
-  },
-});
diff --git a/services/platform/convex/lib/sops.ts b/services/platform/convex/lib/sops.ts
index ab2b33e18a..76464d7f7f 100644
--- a/services/platform/convex/lib/sops.ts
+++ b/services/platform/convex/lib/sops.ts
@@ -4,10 +4,10 @@
  * SOPS decryption utility.
  *
  * Decrypts SOPS-encrypted JSON files using the `sops` CLI binary.
- * Results are cached in memory and invalidated explicitly via `clearCache()`.
+ * Results are cached in memory and invalidated when the file's mtime changes.
  */
 
-import { execSync } from 'node:child_process';
+import { execFileSync } from 'node:child_process';
 import { stat } from 'node:fs/promises';
 
 interface CacheEntry {
@@ -28,7 +28,7 @@ export async function decryptSecretsFile(
 
   let stdout: string;
   try {
-    stdout = execSync(`sops -d --output-type json "${filePath}"`, {
+    stdout = execFileSync('sops', ['-d', '--output-type', 'json', filePath], {
       encoding: 'utf-8',
       timeout: 10_000,
       stdio: ['pipe', 'pipe', 'pipe'],
@@ -47,11 +47,3 @@ export async function decryptSecretsFile(
   cache.set(filePath, { data, mtimeMs: fileStat.mtimeMs });
   return data;
 }
-
-export function clearSopsCache(filePath?: string): void {
-  if (filePath) {
-    cache.delete(filePath);
-  } else {
-    cache.clear();
-  }
-}
diff --git a/services/platform/convex/lib/summarization/internal_actions.ts b/services/platform/convex/lib/summarization/internal_actions.ts
index fe3cb87723..c5eb312a01 100644
--- a/services/platform/convex/lib/summarization/internal_actions.ts
+++ b/services/platform/convex/lib/summarization/internal_actions.ts
@@ -1,8 +1,7 @@
-import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
 import { v } from 'convex/values';
 
-import { internal } from '../../_generated/api';
 import { internalAction } from '../../_generated/server';
+import { resolveLanguageModel } from '../../providers/resolve_model';
 import { autoSummarizeIfNeededModel } from './auto_summarize';
 
 export const autoSummarizeIfNeeded = internalAction({
@@ -17,24 +16,7 @@ export const autoSummarizeIfNeeded = internalAction({
   }),
   handler: async (ctx, args) => {
     // Resolve chat model from provider files
-    // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- resolveModelByTag returns v.any() but shape is guaranteed by provider file_actions contract
-    const modelData = (await ctx.runAction(
-      internal.providers.file_actions.resolveModelByTag,
-      { tag: 'chat' },
-    )) as {
-      providerName: string;
-      baseUrl: string;
-      apiKey: string;
-      modelId: string;
-      supportsStructuredOutputs: boolean;
-    };
-    const provider = createOpenAICompatible({
-      name: modelData.providerName,
-      baseURL: modelData.baseUrl,
-      apiKey: modelData.apiKey,
-      supportsStructuredOutputs: modelData.supportsStructuredOutputs,
-    });
-    const languageModel = provider.chatModel(modelData.modelId);
+    const { languageModel } = await resolveLanguageModel(ctx, { tag: 'chat' });
 
     return await autoSummarizeIfNeededModel(ctx, {
       ...args,
diff --git a/services/platform/convex/providers/file_actions.ts b/services/platform/convex/providers/file_actions.ts
index f7800a1cea..e536490c91 100644
--- a/services/platform/convex/providers/file_actions.ts
+++ b/services/platform/convex/providers/file_actions.ts
@@ -95,7 +95,10 @@ async function loadAllProviders(
 
   for (const fileName of jsonFiles) {
     const providerName = path.basename(fileName, '.json');
-    if (!validateProviderName(providerName)) continue;
+    if (!validateProviderName(providerName)) {
+      console.warn(`Provider "${providerName}": invalid name, skipping.`);
+      continue;
+    }
 
     const filePath = path.join(dir, fileName);
     const result = await readJsonFile<ProviderJson>(
@@ -103,16 +106,20 @@ async function loadAllProviders(
       MAX_FILE_SIZE_BYTES,
       parseProviderJson,
     );
-    if (!result.ok) continue;
+    if (!result.ok) {
+      console.warn(`Provider "${providerName}": ${result.message}, skipping.`);
+      continue;
+    }
 
     const secretsPath = path.join(dir, `${providerName}.secrets.json`);
     let secrets: ProviderSecrets;
     try {
       const raw = await decryptSecretsFile(secretsPath);
       secrets = parseProviderSecrets(raw);
-    } catch {
+    } catch (err) {
       console.warn(
-        `Provider "${providerName}": secrets not available, skipping. Configure the API key in the management UI.`,
+        `Provider "${providerName}": secrets not available, skipping.`,
+        err instanceof Error ? err.message : err,
       );
       continue;
     }
@@ -215,8 +222,14 @@ export const deleteProvider = action({
       args.orgSlug,
       args.providerName,
     );
-    await unlink(filePath).catch(() => {});
-    await unlink(secretsPath).catch(() => {});
+    await unlink(filePath).catch((err: unknown) => {
+      // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Node.js errors always have .code
+      if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
+    });
+    await unlink(secretsPath).catch((err: unknown) => {
+      // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Node.js errors always have .code
+      if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
+    });
     return null;
   },
 });
@@ -235,7 +248,14 @@ export const resolveModelData = internalAction({
     orgSlug: v.optional(v.string()),
     providerName: v.optional(v.string()),
   },
-  returns: v.any(),
+  returns: v.object({
+    providerName: v.string(),
+    baseUrl: v.string(),
+    apiKey: v.string(),
+    modelId: v.string(),
+    dimensions: v.optional(v.number()),
+    supportsStructuredOutputs: v.boolean(),
+  }),
   handler: async (_ctx, args) => {
     const orgSlug = args.orgSlug ?? 'default';
     const providers = await loadAllProviders(orgSlug);
@@ -285,7 +305,14 @@ export const resolveModelByTag = internalAction({
     orgSlug: v.optional(v.string()),
     providerName: v.optional(v.string()),
   },
-  returns: v.any(),
+  returns: v.object({
+    providerName: v.string(),
+    baseUrl: v.string(),
+    apiKey: v.string(),
+    modelId: v.string(),
+    dimensions: v.optional(v.number()),
+    supportsStructuredOutputs: v.boolean(),
+  }),
   handler: async (_ctx, args) => {
     const orgSlug = args.orgSlug ?? 'default';
     const providers = await loadAllProviders(orgSlug);
@@ -324,23 +351,6 @@ export const resolveModelByTag = internalAction({
   },
 });
 
-/**
- * Get the default model ID (first model marked default:true, or first model).
- */
-export const getDefaultModelId = internalAction({
-  args: { orgSlug: v.optional(v.string()) },
-  returns: v.string(),
-  handler: async (_ctx, args) => {
-    const orgSlug = args.orgSlug ?? 'default';
-    const providers = await loadAllProviders(orgSlug);
-    for (const provider of providers) {
-      const defaultModel = provider.config.models.find((m) => m.default);
-      if (defaultModel) return defaultModel.id;
-    }
-    return providers[0].config.models[0].id;
-  },
-});
-
 /**
  * Get all provider configs (public data only, no secrets).
  */
@@ -427,19 +437,30 @@ export const saveProviderSecret = action({
     const agePublicKey = deriveAgePublicKey(sopsAgeKey);
 
     const plaintext = JSON.stringify({ apiKey: args.apiKey }, null, 2) + '\n';
-    const { execSync } = await import('node:child_process');
-
-    await atomicWrite(secretsPath, plaintext);
+    const { execFileSync } = await import('node:child_process');
 
+    let encrypted: string;
     try {
-      execSync(`sops -e --age "${agePublicKey}" -i "${secretsPath}"`, {
-        encoding: 'utf-8',
-        timeout: 10_000,
-        stdio: ['pipe', 'pipe', 'pipe'],
-      });
+      encrypted = execFileSync(
+        'sops',
+        [
+          '-e',
+          '--input-type',
+          'json',
+          '--output-type',
+          'json',
+          '--age',
+          agePublicKey,
+          '/dev/stdin',
+        ],
+        {
+          input: plaintext,
+          encoding: 'utf-8',
+          timeout: 10_000,
+          stdio: ['pipe', 'pipe', 'pipe'],
+        },
+      );
     } catch (err) {
-      const { unlink: unlinkFile } = await import('node:fs/promises');
-      await unlinkFile(secretsPath).catch(() => {});
       const message = err instanceof Error ? err.message : String(err);
       throw new Error(
         `Failed to encrypt secrets for "${args.providerName}": ${message}. ` +
@@ -448,6 +469,8 @@ export const saveProviderSecret = action({
       );
     }
 
+    await atomicWrite(secretsPath, encrypted);
+
     return null;
   },
 });
diff --git a/services/platform/convex/providers/file_utils.ts b/services/platform/convex/providers/file_utils.ts
index 138637c4e7..75ae7e88e4 100644
--- a/services/platform/convex/providers/file_utils.ts
+++ b/services/platform/convex/providers/file_utils.ts
@@ -102,7 +102,15 @@ export function resolveProviderSecretsPath(
   if (!validateProviderName(providerName))
     throw new Error(`Invalid provider name: ${providerName}`);
   const dir = resolveProvidersDir(orgSlug);
-  return path.resolve(dir, `${providerName}.secrets.json`);
+  const resolved = path.resolve(dir, `${providerName}.secrets.json`);
+  const expectedPrefix = path.resolve(dir);
+  if (
+    !resolved.startsWith(expectedPrefix + path.sep) &&
+    resolved !== expectedPrefix
+  ) {
+    throw new Error(`Path traversal detected: ${providerName}`);
+  }
+  return resolved;
 }
 
 export { MAX_FILE_SIZE_BYTES };
diff --git a/services/platform/convex/providers/resolve_model.ts b/services/platform/convex/providers/resolve_model.ts
new file mode 100644
index 0000000000..8bf076303c
--- /dev/null
+++ b/services/platform/convex/providers/resolve_model.ts
@@ -0,0 +1,72 @@
+'use node';
+
+/**
+ * Shared helpers for resolving provider models and creating language model instances.
+ *
+ * Centralizes the resolve → create-provider → get-model pattern used across
+ * the codebase, eliminating the repeated type assertions and boilerplate.
+ */
+
+import type { LanguageModelV3 } from '@ai-sdk/provider';
+
+import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
+
+import type { ActionCtx } from '../_generated/server';
+
+import { internal } from '../_generated/api';
+
+export interface ResolvedModelData {
+  providerName: string;
+  baseUrl: string;
+  apiKey: string;
+  modelId: string;
+  dimensions?: number;
+  supportsStructuredOutputs: boolean;
+}
+
+interface ResolvedLanguageModel {
+  languageModel: LanguageModelV3;
+  modelData: ResolvedModelData;
+}
+
+function createLanguageModel(modelData: ResolvedModelData): LanguageModelV3 {
+  const provider = createOpenAICompatible({
+    name: modelData.providerName,
+    baseURL: modelData.baseUrl,
+    apiKey: modelData.apiKey,
+    supportsStructuredOutputs: modelData.supportsStructuredOutputs,
+  });
+  return provider.chatModel(modelData.modelId);
+}
+
+/**
+ * Resolve a language model by tag (e.g., 'chat', 'vision').
+ * Searches all providers (or a specific one if providerName is given).
+ */
+export async function resolveLanguageModel(
+  ctx: ActionCtx,
+  opts: { tag: string; providerName?: string },
+): Promise<ResolvedLanguageModel> {
+  // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- resolveModelByTag returns v.any() but shape is guaranteed by file_actions contract
+  const modelData = (await ctx.runAction(
+    internal.providers.file_actions.resolveModelByTag,
+    { tag: opts.tag, providerName: opts.providerName },
+  )) as ResolvedModelData;
+  return { languageModel: createLanguageModel(modelData), modelData };
+}
+
+/**
+ * Resolve a language model by explicit model ID.
+ * Searches all providers (or a specific one if providerName is given).
+ */
+export async function resolveLanguageModelById(
+  ctx: ActionCtx,
+  opts: { modelId: string; providerName?: string },
+): Promise<ResolvedLanguageModel> {
+  // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- resolveModelData returns v.any() but shape is guaranteed by file_actions contract
+  const modelData = (await ctx.runAction(
+    internal.providers.file_actions.resolveModelData,
+    { modelId: opts.modelId, providerName: opts.providerName },
+  )) as ResolvedModelData;
+  return { languageModel: createLanguageModel(modelData), modelData };
+}
diff --git a/services/platform/convex/providers/validators.ts b/services/platform/convex/providers/validators.ts
index 3c843eb4f9..0bbf7a31c8 100644
--- a/services/platform/convex/providers/validators.ts
+++ b/services/platform/convex/providers/validators.ts
@@ -1,4 +1,4 @@
-const PROVIDER_NAME_REGEX = /^[a-z0-9][a-z0-9_-]*$/;
+const PROVIDER_NAME_REGEX = /^[a-z0-9][a-z0-9_-]{0,99}$/;
 
 export function validateProviderName(name: string): boolean {
   return PROVIDER_NAME_REGEX.test(name);
diff --git a/services/platform/convex/workflow_engine/helpers/nodes/llm/execute_llm_node.ts b/services/platform/convex/workflow_engine/helpers/nodes/llm/execute_llm_node.ts
index 65d6d27531..e75c88c2c7 100644
--- a/services/platform/convex/workflow_engine/helpers/nodes/llm/execute_llm_node.ts
+++ b/services/platform/convex/workflow_engine/helpers/nodes/llm/execute_llm_node.ts
@@ -5,13 +5,11 @@
  * Uses AI SDK with OpenAI provider and Agent SDK for tool integration.
  */
 
-import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
-
 import type { Id } from '../../../../_generated/dataModel';
 import type { ActionCtx } from '../../../../_generated/server';
 import type { StepExecutionResult, LLMNodeConfig } from '../../../types';
 
-import { internal } from '../../../../_generated/api';
+import { resolveLanguageModel } from '../../../../providers/resolve_model';
 import { executeAgentWithTools } from './execute_agent_with_tools';
 import { createLLMResult } from './utils/create_llm_result';
 import { processPrompts } from './utils/process_prompts';
@@ -40,24 +38,8 @@ export async function executeLLMNode(
   stepSlug?: string,
 ): Promise<StepExecutionResult> {
   // 1. Resolve default model from provider files, then validate and normalize config
-  // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- resolveModelByTag returns v.any() but shape is guaranteed by provider file_actions contract
-  const chatModelData = (await ctx.runAction(
-    internal.providers.file_actions.resolveModelByTag,
-    { tag: 'chat' },
-  )) as {
-    providerName: string;
-    baseUrl: string;
-    apiKey: string;
-    modelId: string;
-    supportsStructuredOutputs: boolean;
-  };
-  const providerInstance = createOpenAICompatible({
-    name: chatModelData.providerName,
-    baseURL: chatModelData.baseUrl,
-    apiKey: chatModelData.apiKey,
-    supportsStructuredOutputs: chatModelData.supportsStructuredOutputs,
-  });
-  const languageModel = providerInstance.chatModel(chatModelData.modelId);
+  const { languageModel, modelData: chatModelData } =
+    await resolveLanguageModel(ctx, { tag: 'chat' });
   const normalizedConfig = validateAndNormalizeConfig(
     config,
     chatModelData.modelId,
diff --git a/services/platform/convex/workflow_engine/helpers/nodes/llm/utils/validate_and_normalize_config.ts b/services/platform/convex/workflow_engine/helpers/nodes/llm/utils/validate_and_normalize_config.ts
index 4d770497f4..81f44815d2 100644
--- a/services/platform/convex/workflow_engine/helpers/nodes/llm/utils/validate_and_normalize_config.ts
+++ b/services/platform/convex/workflow_engine/helpers/nodes/llm/utils/validate_and_normalize_config.ts
@@ -74,9 +74,9 @@ export function validateAndNormalizeConfig(
     name: llmConfig.name || 'Workflow LLM',
     systemPrompt: llmConfig.systemPrompt,
     userPrompt: llmConfig.userPrompt || '',
-    // Model is controlled via the OPENAI_MODEL environment variable. We do not
-    // provide a fallback model: OPENAI_MODEL must be set explicitly in the
-    // Convex environment or LLM workflow steps will fail to run.
+    // Model is resolved from provider configuration files. We do not provide a
+    // fallback model: a valid provider config must exist or LLM workflow steps
+    // will fail to run.
     model: envModel,
     // Note: The following are intentionally not configurable in workflow definitions:
     // - maxTokens: uses model's default value
diff --git a/services/platform/convex/workflow_engine/helpers/validation/steps/llm.ts b/services/platform/convex/workflow_engine/helpers/validation/steps/llm.ts
index d612b15f30..17186134a1 100644
--- a/services/platform/convex/workflow_engine/helpers/validation/steps/llm.ts
+++ b/services/platform/convex/workflow_engine/helpers/validation/steps/llm.ts
@@ -209,7 +209,7 @@ export function validateLlmStep(
     }
   }
 
-  // Model is now resolved from environment (OPENAI_MODEL) and cannot be
+  // Model is resolved from provider configuration files and cannot be
   // customized per step, so we intentionally do not validate a model field.
   // Any provided model value will be ignored at execution time.
 
diff --git a/services/platform/convex/workflow_engine/types/nodes.ts b/services/platform/convex/workflow_engine/types/nodes.ts
index 695c0cd954..be45a45dee 100644
--- a/services/platform/convex/workflow_engine/types/nodes.ts
+++ b/services/platform/convex/workflow_engine/types/nodes.ts
@@ -50,8 +50,8 @@ export interface LLMNodeConfig {
   description?: string;
 
   // Model configuration (provider-agnostic; any OpenAI-compatible model id)
-  // Model is now controlled centrally via environment (OPENAI_MODEL) and cannot
-  // be customized per step.
+  // Model is resolved from provider configuration files and cannot be
+  // customized per step.
   // Temperature is automatically determined based on outputFormat:
   // - json → 0.2 (more deterministic for structured output)
   // - text → 0.5 (balanced creativity)
@@ -150,7 +150,7 @@ export const llmNodeConfigValidator = v.object({
   description: v.optional(v.string()),
 
   // Allow arbitrary model ids so users can target any OpenAI-compatible provider
-  // NOTE: Model is resolved from environment (OPENAI_MODEL) at runtime. This
+  // NOTE: Model is resolved from provider configuration files at runtime. This
   // field is optional and, if provided, is ignored by execution.
   // Temperature is auto-determined based on outputFormat (json→0.2, text→0.5).
   model: v.optional(v.string()),
diff --git a/services/platform/convex/workflows/triggers/actions.ts b/services/platform/convex/workflows/triggers/actions.ts
index 6652dd3553..85b481890e 100644
--- a/services/platform/convex/workflows/triggers/actions.ts
+++ b/services/platform/convex/workflows/triggers/actions.ts
@@ -1,14 +1,13 @@
 'use node';
 
-import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
 import { generateObject } from 'ai';
 import { v } from 'convex/values';
 import { CronExpressionParser } from 'cron-parser';
 import { z } from 'zod/v4';
 
-import { internal } from '../../_generated/api';
 import { action } from '../../_generated/server';
 import { authComponent } from '../../auth';
+import { resolveLanguageModel } from '../../providers/resolve_model';
 
 export const generateCronExpression = action({
   args: {
@@ -33,26 +32,10 @@ export const generateCronExpression = action({
     }
 
     // Resolve chat model from provider files
-    // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- resolveModelByTag returns v.any() but shape is guaranteed by provider file_actions contract
-    const modelData = (await ctx.runAction(
-      internal.providers.file_actions.resolveModelByTag,
-      { tag: 'chat' },
-    )) as {
-      providerName: string;
-      baseUrl: string;
-      apiKey: string;
-      modelId: string;
-      supportsStructuredOutputs: boolean;
-    };
-    const providerInstance = createOpenAICompatible({
-      name: modelData.providerName,
-      baseURL: modelData.baseUrl,
-      apiKey: modelData.apiKey,
-      supportsStructuredOutputs: modelData.supportsStructuredOutputs,
-    });
+    const { languageModel } = await resolveLanguageModel(ctx, { tag: 'chat' });
 
     const result = await generateObject({
-      model: providerInstance.chatModel(modelData.modelId),
+      model: languageModel,
       temperature: 0.1,
       schema: z.object({
         cronExpression: z
diff --git a/services/platform/docker-entrypoint.sh b/services/platform/docker-entrypoint.sh
index 3fd8c46552..838712f869 100644
--- a/services/platform/docker-entrypoint.sh
+++ b/services/platform/docker-entrypoint.sh
@@ -505,12 +505,18 @@ if [ -d "$providers_builtin_dir" ] && [ "$(ls -A "$providers_builtin_dir" 2>/dev
   for src in "$providers_builtin_dir"/*.json; do
     [ -f "$src" ] || continue
     name="$(basename "$src")"
+    # Skip encrypted secrets files
+    [[ "$name" == *.secrets.json ]] && continue
+    slug="$(basename "$src" .json)"
     dest="$providers_dir/$name"
+    history_dir="$providers_dir/.history/$slug"
     if [ "$FORCE_SEED" = "true" ]; then
       cp "$src" "$dest"
       echo "   ✓ Seeded provider $name (forced)"
     elif [ -f "$dest" ]; then
       echo "   ⏭ Skipping provider $name (already exists)"
+    elif [ -d "$history_dir" ] && [ "$(ls -A "$history_dir" 2>/dev/null)" ]; then
+      echo "   ⏭ Skipping provider $name (user has modifications in .history)"
     else
       cp "$src" "$dest"
     fi
diff --git a/services/platform/lib/shared/schemas/providers.ts b/services/platform/lib/shared/schemas/providers.ts
index 1bd6792c87..4f6eba3754 100644
--- a/services/platform/lib/shared/schemas/providers.ts
+++ b/services/platform/lib/shared/schemas/providers.ts
@@ -31,7 +31,16 @@ export const providerJsonSchema = z.object({
   description: z.string().max(1000).optional(),
   baseUrl: z.string().url(),
   supportsStructuredOutputs: z.boolean().optional(),
-  models: z.array(modelDefinitionSchema).min(1),
+  models: z
+    .array(modelDefinitionSchema)
+    .min(1)
+    .refine(
+      (models) => new Set(models.map((m) => m.id)).size === models.length,
+      { message: 'Model IDs must be unique' },
+    )
+    .refine((models) => models.filter((m) => m.default).length <= 1, {
+      message: 'At most one model can be marked as default',
+    }),
   i18n: z
     .record(
       z.string().regex(/^[a-z]{2}(-[A-Z]{2})?$/),
diff --git a/services/platform/messages/en.json b/services/platform/messages/en.json
index fd2a6a4079..716e357f0d 100644
--- a/services/platform/messages/en.json
+++ b/services/platform/messages/en.json
@@ -1459,7 +1459,15 @@
       "modelCount": "{count, plural, one {# model} other {# models}}",
       "removeModel": "Remove model",
       "apiKeyPlaceholder": "Enter API key",
-      "apiKeyReplacePlaceholder": "Enter new API key to replace ({maskedKey})"
+      "apiKeyReplacePlaceholder": "Enter new API key to replace ({maskedKey})",
+      "providerNotFound": "Provider not found: {name}",
+      "backToProviders": "Back to providers",
+      "tagChat": "Chat",
+      "tagVision": "Vision",
+      "tagEmbedding": "Embedding",
+      "displayNameRequired": "Display name is required",
+      "invalidBaseUrl": "Base URL must be a valid URL",
+      "modelIdRequired": "All models must have an ID"
     },
     "form": {
       "name": "Name",
diff --git a/tools/cli/src/commands/reset.ts b/tools/cli/src/commands/reset.ts
index ac9670b80e..a76d4f9e80 100644
--- a/tools/cli/src/commands/reset.ts
+++ b/tools/cli/src/commands/reset.ts
@@ -15,7 +15,7 @@ export function createResetCommand(): Command {
     .action(async (options) => {
       try {
         const deployDir = await ensureConfig();
-        const envSetupSuccess = await ensureEnv({ deployDir });
+        const { success: envSetupSuccess } = await ensureEnv({ deployDir });
         if (!envSetupSuccess) {
           process.exit(1);
         }
diff --git a/tools/cli/src/commands/rollback.ts b/tools/cli/src/commands/rollback.ts
index 0f5dd577d3..cd4ec71b5e 100644
--- a/tools/cli/src/commands/rollback.ts
+++ b/tools/cli/src/commands/rollback.ts
@@ -16,7 +16,7 @@ export function createRollbackCommand(): Command {
     .action(async (options) => {
       try {
         const deployDir = await ensureConfig();
-        const envSetupSuccess = await ensureEnv({ deployDir });
+        const { success: envSetupSuccess } = await ensureEnv({ deployDir });
         if (!envSetupSuccess) {
           process.exit(1);
         }
diff --git a/tools/cli/src/lib/actions/init.ts b/tools/cli/src/lib/actions/init.ts
index 9c9ea4cd86..5e388dd657 100644
--- a/tools/cli/src/lib/actions/init.ts
+++ b/tools/cli/src/lib/actions/init.ts
@@ -80,6 +80,17 @@ export async function init(options: InitOptions): Promise<void> {
   await writeFile(join(target, 'branding', 'branding.json'), '{}\n');
   await writeFile(join(target, 'branding', 'images', '.gitkeep'), '');
 
+  // Copy provider configs (public JSON only, not encrypted secrets)
+  logger.step('Copying provider configurations...');
+  const providerFiles = getEmbeddedExamples('providers');
+  const providerConfigFiles = new Map<string, string>();
+  for (const [relPath, content] of providerFiles) {
+    if (!relPath.endsWith('.secrets.json')) {
+      providerConfigFiles.set(relPath, content);
+    }
+  }
+  await writeEmbeddedFiles(providerConfigFiles, join(target, 'providers'));
+
   // Compute checksums
   logger.step('Computing file checksums...');
   const allFiles = new Map<string, string>();
@@ -93,6 +104,9 @@ export async function init(options: InitOptions): Promise<void> {
   for (const [relPath, content] of integrationFiles) {
     allFiles.set(join('integrations', relPath), computeContentHash(content));
   }
+  for (const [relPath, content] of providerConfigFiles) {
+    allFiles.set(join('providers', relPath), computeContentHash(content));
+  }
   allFiles.set(join('branding', 'branding.json'), computeContentHash('{}\n'));
 
   const checksums: Checksums = {
@@ -122,17 +136,6 @@ export async function init(options: InitOptions): Promise<void> {
   // Ensure .gitignore
   await ensureGitignore(target);
 
-  // Copy provider configs (public JSON only, not encrypted secrets)
-  logger.step('Copying provider configurations...');
-  const providerFiles = getEmbeddedExamples('providers');
-  const providerConfigFiles = new Map<string, string>();
-  for (const [relPath, content] of providerFiles) {
-    if (!relPath.endsWith('.secrets.json')) {
-      providerConfigFiles.set(relPath, content);
-    }
-  }
-  await writeEmbeddedFiles(providerConfigFiles, join(target, 'providers'));
-
   // .env setup
   if (!options.noEnv) {
     const { ensureEnv } = await import('../config/ensure-env');
diff --git a/tools/cli/src/lib/actions/update.ts b/tools/cli/src/lib/actions/update.ts
index 130cfcf3b2..3927007894 100644
--- a/tools/cli/src/lib/actions/update.ts
+++ b/tools/cli/src/lib/actions/update.ts
@@ -87,6 +87,11 @@ export async function update(options: UpdateOptions): Promise<void> {
   for (const [relPath, content] of getEmbeddedExamples('branding')) {
     newExampleFiles.set(join('branding', relPath), content);
   }
+  for (const [relPath, content] of getEmbeddedExamples('providers')) {
+    if (!relPath.endsWith('.secrets.json')) {
+      newExampleFiles.set(join('providers', relPath), content);
+    }
+  }
 
   // Classify and apply changes
   const summary: UpdateSummary = {
diff --git a/tools/cli/src/lib/crypto/age-keygen.ts b/tools/cli/src/lib/crypto/age-keygen.ts
index b63ad4d611..7afeba382e 100644
--- a/tools/cli/src/lib/crypto/age-keygen.ts
+++ b/tools/cli/src/lib/crypto/age-keygen.ts
@@ -42,6 +42,7 @@ export function deriveAgePublicKey(secretKey: string): string {
   if (!lowercase.includes('1')) {
     throw new Error('Invalid age secret key: missing bech32 separator');
   }
+  // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- The includes('1') guard above proves the bech32 separator exists
   const decoded = bech32.decode(lowercase as `${string}1${string}`, false);
   if (decoded.prefix !== SECRET_HRP) {
     throw new Error(`Invalid age secret key prefix: "${decoded.prefix}"`);

From f5fbaeab39cd2885be107d703772abebadd9644e Mon Sep 17 00:00:00 2001
From: larryro <371767072@qq.com>
Date: Fri, 3 Apr 2026 16:27:59 +0800
Subject: [PATCH 11/13] fix(platform): pass resolved provider name to message
 metadata
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The provider name was lost during model resolution — only the
languageModel was destructured, discarding modelData.providerName.
This caused messageMetadata to store an empty string for provider
and the UI to render empty parentheses next to the model name.

Capture modelData from resolveLanguageModel and pass the resolved
provider name through the generation chain. Also guard the UI so
old records with empty provider don't show "()".
---
 .../app/features/chat/components/message-info-dialog.tsx     | 3 ++-
 services/platform/convex/lib/agent_chat/internal_actions.ts  | 5 +++--
 2 files changed, 5 insertions(+), 3 deletions(-)

diff --git a/services/platform/app/features/chat/components/message-info-dialog.tsx b/services/platform/app/features/chat/components/message-info-dialog.tsx
index d53d48aa74..b38c2e0c9f 100644
--- a/services/platform/app/features/chat/components/message-info-dialog.tsx
+++ b/services/platform/app/features/chat/components/message-info-dialog.tsx
@@ -272,7 +272,8 @@ export function MessageInfoDialog({
           <>
             <Field label={t('messageInfo.model')}>
               <Text as="div">
-                {metadata.model} ({metadata.provider})
+                {metadata.model}
+                {metadata.provider ? ` (${metadata.provider})` : ''}
               </Text>
             </Field>
 
diff --git a/services/platform/convex/lib/agent_chat/internal_actions.ts b/services/platform/convex/lib/agent_chat/internal_actions.ts
index 46cb18c9ba..e011df8d71 100644
--- a/services/platform/convex/lib/agent_chat/internal_actions.ts
+++ b/services/platform/convex/lib/agent_chat/internal_actions.ts
@@ -283,7 +283,7 @@ export const runAgentGeneration = internalAction({
 
     // Resolve model from provider files
     const modelId = model === 'default' ? undefined : model;
-    const { languageModel } = modelId
+    const { languageModel, modelData } = modelId
       ? await resolveLanguageModelById(ctx, {
           modelId,
           providerName: agentConfig.provider,
@@ -292,6 +292,7 @@ export const runAgentGeneration = internalAction({
           tag: 'chat',
           providerName: agentConfig.provider,
         });
+    const resolvedProvider = modelData.providerName;
 
     // Create agent factory function from serializable config
     const createAgent = () => {
@@ -328,7 +329,7 @@ export const runAgentGeneration = internalAction({
           agentType,
           createAgent,
           model,
-          provider,
+          provider: resolvedProvider,
           debugTag,
           enableStreaming,
           hooks,

From 175a4a136f3c6af3e6c7b5e675c1c2318695f5a6 Mon Sep 17 00:00:00 2001
From: larryro <371767072@qq.com>
Date: Fri, 3 Apr 2026 16:36:48 +0800
Subject: [PATCH 12/13] fix: resolve lint, format, and knip CI failures

Prefix unused destructured `provider` variable, add @ai-sdk/provider
as explicit dependency, remove unused exports, and fix package.json
formatting.
---
 bun.lock                                                    | 1 +
 services/platform/convex/lib/agent_chat/internal_actions.ts | 2 +-
 services/platform/lib/shared/schemas/providers.ts           | 6 +++---
 services/platform/package.json                              | 5 +++--
 tools/cli/src/lib/config/ensure-env.ts                      | 2 +-
 tools/cli/src/lib/crypto/age-keygen.ts                      | 4 ++--
 6 files changed, 11 insertions(+), 9 deletions(-)

diff --git a/bun.lock b/bun.lock
index 02ca90ecc7..51f2bef322 100644
--- a/bun.lock
+++ b/bun.lock
@@ -38,6 +38,7 @@
       "version": "0.1.0",
       "dependencies": {
         "@ai-sdk/openai-compatible": "^2.0.37",
+        "@ai-sdk/provider": "^3.0.0",
         "@ai-sdk/provider-utils": "^4.0.6",
         "@casl/ability": "^6.8.0",
         "@convex-dev/action-cache": "0.3.0",
diff --git a/services/platform/convex/lib/agent_chat/internal_actions.ts b/services/platform/convex/lib/agent_chat/internal_actions.ts
index e011df8d71..b78cea304e 100644
--- a/services/platform/convex/lib/agent_chat/internal_actions.ts
+++ b/services/platform/convex/lib/agent_chat/internal_actions.ts
@@ -150,7 +150,7 @@ export const runAgentGeneration = internalAction({
       agentType: agentTypeStr,
       agentConfig,
       model,
-      provider,
+      provider: _provider,
       debugTag,
       enableStreaming,
       hooks: hooksConfig,
diff --git a/services/platform/lib/shared/schemas/providers.ts b/services/platform/lib/shared/schemas/providers.ts
index 4f6eba3754..dd7ca88802 100644
--- a/services/platform/lib/shared/schemas/providers.ts
+++ b/services/platform/lib/shared/schemas/providers.ts
@@ -1,8 +1,8 @@
 import { z } from 'zod/v4';
 
 const modelTagLiterals = ['chat', 'vision', 'embedding'] as const;
-export const modelTagSchema = z.enum(modelTagLiterals);
-export type ModelTag = z.infer<typeof modelTagSchema>;
+const modelTagSchema = z.enum(modelTagLiterals);
+type ModelTag = z.infer<typeof modelTagSchema>;
 
 const modelDefinitionSchema = z.object({
   id: z.string().min(1).max(200),
@@ -13,7 +13,7 @@ const modelDefinitionSchema = z.object({
   dimensions: z.number().int().positive().optional(),
 });
 
-export type ModelDefinition = z.infer<typeof modelDefinitionSchema>;
+type ModelDefinition = z.infer<typeof modelDefinitionSchema>;
 
 const translatableModelFieldsSchema = z.object({
   displayName: z.string().min(1).max(200).optional(),
diff --git a/services/platform/package.json b/services/platform/package.json
index 2a5f729f18..10c952a2a6 100644
--- a/services/platform/package.json
+++ b/services/platform/package.json
@@ -35,8 +35,7 @@
   },
   "dependencies": {
     "@ai-sdk/openai-compatible": "^2.0.37",
-    "@noble/curves": "^1.8.0",
-    "@scure/base": "^1.2.0",
+    "@ai-sdk/provider": "^3.0.0",
     "@ai-sdk/provider-utils": "^4.0.6",
     "@casl/ability": "^6.8.0",
     "@convex-dev/action-cache": "0.3.0",
@@ -51,6 +50,7 @@
     "@microlink/react-json-view": "1.31.15",
     "@milkdown/crepe": "7.18.0",
     "@milkdown/react": "7.18.0",
+    "@noble/curves": "^1.8.0",
     "@radix-ui/react-checkbox": "1.3.3",
     "@radix-ui/react-dialog": "1.1.15",
     "@radix-ui/react-dropdown-menu": "2.1.16",
@@ -66,6 +66,7 @@
     "@radix-ui/react-toast": "1.2.15",
     "@radix-ui/react-tooltip": "1.2.8",
     "@radix-ui/react-visually-hidden": "1.2.4",
+    "@scure/base": "^1.2.0",
     "@sentry/tanstackstart-react": "^10.37.0",
     "@tailwindcss/postcss": "4.2.2",
     "@tanstack/react-query": "5.96.1",
diff --git a/tools/cli/src/lib/config/ensure-env.ts b/tools/cli/src/lib/config/ensure-env.ts
index 43815f42f7..d54b6d3942 100644
--- a/tools/cli/src/lib/config/ensure-env.ts
+++ b/tools/cli/src/lib/config/ensure-env.ts
@@ -39,7 +39,7 @@ interface EnvSetupOptions {
   skipIfExists?: boolean;
 }
 
-export interface EnvSetupResult {
+interface EnvSetupResult {
   success: boolean;
   agePublicKey?: string;
 }
diff --git a/tools/cli/src/lib/crypto/age-keygen.ts b/tools/cli/src/lib/crypto/age-keygen.ts
index 7afeba382e..b604c9fbe0 100644
--- a/tools/cli/src/lib/crypto/age-keygen.ts
+++ b/tools/cli/src/lib/crypto/age-keygen.ts
@@ -8,7 +8,7 @@
 import { x25519 } from '@noble/curves/ed25519';
 import { bech32 } from '@scure/base';
 
-export interface AgeKeypair {
+interface AgeKeypair {
   secretKey: string; // "AGE-SECRET-KEY-1..."
   publicKey: string; // "age1..."
 }
@@ -37,7 +37,7 @@ export function generateAgeKeypair(): AgeKeypair {
  * @param secretKey - The "AGE-SECRET-KEY-1..." string
  * @returns The corresponding "age1..." public key
  */
-export function deriveAgePublicKey(secretKey: string): string {
+function deriveAgePublicKey(secretKey: string): string {
   const lowercase = secretKey.toLowerCase();
   if (!lowercase.includes('1')) {
     throw new Error('Invalid age secret key: missing bech32 separator');

From 7c4928dfe308f70fae5131fdcf2059c2f4e1858d Mon Sep 17 00:00:00 2001
From: larryro <371767072@qq.com>
Date: Fri, 3 Apr 2026 16:38:54 +0800
Subject: [PATCH 13/13] fix(cli): remove unused deriveAgePublicKey function

---
 tools/cli/src/lib/crypto/age-keygen.ts | 21 ---------------------
 1 file changed, 21 deletions(-)

diff --git a/tools/cli/src/lib/crypto/age-keygen.ts b/tools/cli/src/lib/crypto/age-keygen.ts
index b604c9fbe0..4cb2c2e9a1 100644
--- a/tools/cli/src/lib/crypto/age-keygen.ts
+++ b/tools/cli/src/lib/crypto/age-keygen.ts
@@ -30,24 +30,3 @@ export function generateAgeKeypair(): AgeKeypair {
 
   return { secretKey, publicKey };
 }
-
-/**
- * Derive the age public key from an existing secret key string.
- *
- * @param secretKey - The "AGE-SECRET-KEY-1..." string
- * @returns The corresponding "age1..." public key
- */
-function deriveAgePublicKey(secretKey: string): string {
-  const lowercase = secretKey.toLowerCase();
-  if (!lowercase.includes('1')) {
-    throw new Error('Invalid age secret key: missing bech32 separator');
-  }
-  // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- The includes('1') guard above proves the bech32 separator exists
-  const decoded = bech32.decode(lowercase as `${string}1${string}`, false);
-  if (decoded.prefix !== SECRET_HRP) {
-    throw new Error(`Invalid age secret key prefix: "${decoded.prefix}"`);
-  }
-  const secretBytes = bech32.fromWords(decoded.words);
-  const publicBytes = x25519.getPublicKey(new Uint8Array(secretBytes));
-  return bech32.encode(PUBLIC_HRP, bech32.toWords(publicBytes));
-}
