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; - modelPresets: Record | 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 ( - - {presetLabel} - {modelName && ( - - {modelName} - - )} - - ); + const model = row.original.supportedModels?.[0]; + if (!model) return null; + return {model}; }, 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({ + 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 ( + { + if (!isOpen) reset(); + onOpenChange(isOpen); + }} + title={t('providers.addProvider')} + submitText={tCommon('actions.create')} + submittingText={tCommon('actions.adding')} + isSubmitting={isSubmitting} + onSubmit={handleSubmit(onSubmit)} + > + + + {t('providers.nameHelp')} + + + + + + + ); +} 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([]); + 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) => { + 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 ( + <> + + {isLoading ? ( + + + + + + ) : ( + + {/* General section */} + + + setDisplayName(e.target.value)} + placeholder={t('providers.displayNamePlaceholder')} + /> +