@palettelab/cli 0.3.32 → 0.3.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -3
- package/backend-sdk/palette_sdk/__init__.py +11 -1
- package/backend-sdk/palette_sdk/manifest.py +1 -1
- package/backend-sdk/palette_sdk/platform_services.py +377 -0
- package/backend-sdk/palette_sdk/plugin_context.py +5 -0
- package/backend-sdk/pyproject.toml +1 -1
- package/docs/python-backend-sdk.md +198 -1
- package/lib/dev-simulator.js +21 -0
- package/lib/manifest.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -249,7 +249,13 @@ def upgrade():
|
|
|
249
249
|
ensure_org_rls(op, "my_app__invoices")
|
|
250
250
|
```
|
|
251
251
|
|
|
252
|
-
Use `ctx.
|
|
252
|
+
Use `ctx.db` for the full database feature set. It is the real async
|
|
253
|
+
SQLAlchemy session, scoped by Palette to the app schema and current
|
|
254
|
+
organization, so app code can use Core/ORM queries, joins, aggregates,
|
|
255
|
+
transactions, bulk operations, and raw `text()` SQL. Use migrations for schema
|
|
256
|
+
changes such as tables, indexes, constraints, and types.
|
|
257
|
+
|
|
258
|
+
Use `ctx.repo(Model)` when simple org-safe CRUD is enough:
|
|
253
259
|
|
|
254
260
|
```python
|
|
255
261
|
from fastapi import Depends
|
|
@@ -269,12 +275,15 @@ async def create_invoice(body: InvoiceIn, ctx: PluginContext = Depends(get_plugi
|
|
|
269
275
|
|
|
270
276
|
Backend SDK features for app-owned data:
|
|
271
277
|
|
|
272
|
-
- `PluginContext` exposes `user_id`, `organization_id`, `plugin_id`, `permissions`, `config`, `storage`, and `ctx.
|
|
278
|
+
- `PluginContext` exposes `user_id`, `organization_id`, `plugin_id`, `permissions`, `config`, `storage`, `ctx.db`, `ctx.redis`, and `ctx.vector`.
|
|
279
|
+
- `ctx.db` is the full scoped SQLAlchemy `AsyncSession` for app-owned database data.
|
|
273
280
|
- `ctx.repo(Model)` gives org-safe CRUD helpers for app tables.
|
|
274
281
|
- `ctx.data_rooms` gives backend access to Palette Data Rooms without importing platform internals.
|
|
275
282
|
- `ctx.has_permission("...")`, `ctx.has_any_permission([...])`, and `ctx.has_all_permissions([...])` check declared permissions.
|
|
276
283
|
- `ctx.config_value("key")` and `ctx.require_config("key")` read app install/config values.
|
|
277
284
|
- `ctx.secret("KEY")` reads app secrets from config or environment variables.
|
|
285
|
+
- `ctx.redis` gives a Redis-backed, plugin/org-scoped Redis API when `"redis"` is declared in `platform_services`.
|
|
286
|
+
- `ctx.vector` gives a Qdrant-backed, plugin/org-scoped vector API when `"vector"` is declared in `platform_services`.
|
|
278
287
|
- `LifecycleHooks` lets apps define install/update/enable/disable/uninstall hooks.
|
|
279
288
|
- `OrgScopedTable` and `PluginBase` keep app data inside the plugin schema model set.
|
|
280
289
|
|
|
@@ -456,7 +465,7 @@ Palette secrets are declared in `palette-plugin.json` and resolved through
|
|
|
456
465
|
"STRIPE_SECRET": { "scope": "plugin", "required": true },
|
|
457
466
|
"DEBUG_PROBE_URL": { "scope": "dev", "required": false }
|
|
458
467
|
},
|
|
459
|
-
"platform_services": ["llm", "
|
|
468
|
+
"platform_services": ["llm", "redis", "storage", "vector"]
|
|
460
469
|
}
|
|
461
470
|
```
|
|
462
471
|
|
|
@@ -475,6 +484,76 @@ never uploaded. `plugin` secrets are encrypted by the platform and attached to
|
|
|
475
484
|
the plugin/environment. `install` secrets are filled by the installing org.
|
|
476
485
|
Frontend bundles may only receive public values such as `NEXT_PUBLIC_*`.
|
|
477
486
|
|
|
487
|
+
Managed platform services do not require developer Redis/Qdrant keys. Declare
|
|
488
|
+
them and use scoped SDK clients:
|
|
489
|
+
|
|
490
|
+
```python
|
|
491
|
+
await ctx.redis.set("cart:123", {"items": [1, 2]}, ttl=3600)
|
|
492
|
+
cart = await ctx.redis.get("cart:123")
|
|
493
|
+
|
|
494
|
+
await ctx.vector.upsert_texts(
|
|
495
|
+
"products",
|
|
496
|
+
[{"id": "p1", "text": "Red cotton shirt", "metadata": {"category": "clothing"}}],
|
|
497
|
+
)
|
|
498
|
+
matches = await ctx.vector.search("products", query="red shirt", top_k=5)
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
The platform rewrites Redis keys and vector filters with `plugin_id` and
|
|
502
|
+
`organization_id`, so create/update/delete/list operations cannot reach another
|
|
503
|
+
app or org's data.
|
|
504
|
+
|
|
505
|
+
For provider-level Redis features, use `ctx.redis.execute(...)`. It forwards to
|
|
506
|
+
Redis after rewriting key arguments into the plugin/org namespace, and blocks
|
|
507
|
+
server/admin commands such as `FLUSHDB`, `CONFIG`, `KEYS`, and cluster
|
|
508
|
+
management. For advanced Qdrant features, use `ctx.vector.client()` with
|
|
509
|
+
`collection_name()`, `scoped_filter()`, `merge_filter()`, `scoped_payload()`,
|
|
510
|
+
and `scoped_point()` so custom calls remain scoped.
|
|
511
|
+
|
|
512
|
+
Common managed-service commands:
|
|
513
|
+
|
|
514
|
+
```python
|
|
515
|
+
# Redis strings, counters, and key discovery
|
|
516
|
+
await ctx.redis.get("key", default=None)
|
|
517
|
+
await ctx.redis.set("key", {"json": True}, ttl=600)
|
|
518
|
+
await ctx.redis.delete("key1", "key2")
|
|
519
|
+
await ctx.redis.exists("key")
|
|
520
|
+
await ctx.redis.expire("key", 300)
|
|
521
|
+
await ctx.redis.ttl("key")
|
|
522
|
+
await ctx.redis.incr("counter")
|
|
523
|
+
await ctx.redis.decr("counter")
|
|
524
|
+
await ctx.redis.scan(prefix="cache:", limit=100)
|
|
525
|
+
|
|
526
|
+
# Redis hashes, lists, sets, sorted sets, streams, locks
|
|
527
|
+
await ctx.redis.hset("hash", "field", {"value": 1})
|
|
528
|
+
await ctx.redis.hgetall("hash")
|
|
529
|
+
await ctx.redis.lpush("queue", {"job": 1})
|
|
530
|
+
await ctx.redis.lrange("queue", 0, -1)
|
|
531
|
+
await ctx.redis.sadd("tags", "red", "blue")
|
|
532
|
+
await ctx.redis.smembers("tags")
|
|
533
|
+
await ctx.redis.zadd("scores", {"alice": 10})
|
|
534
|
+
await ctx.redis.zrange("scores", 0, -1, with_scores=True)
|
|
535
|
+
await ctx.redis.xadd("events", {"type": "created"})
|
|
536
|
+
await ctx.redis.xread({"events": "0-0"}, count=10)
|
|
537
|
+
await ctx.redis.lock("invoice:1", token, ttl=30)
|
|
538
|
+
await ctx.redis.unlock("invoice:1", token)
|
|
539
|
+
|
|
540
|
+
# Redis provider-style data-plane calls
|
|
541
|
+
await ctx.redis.execute("MSET", "a", "1", "b", "2")
|
|
542
|
+
values = await ctx.redis.execute("MGET", "a", "b")
|
|
543
|
+
|
|
544
|
+
# Vector search
|
|
545
|
+
await ctx.vector.upsert_texts("knowledge", [{"id": "doc-1", "text": "Text"}])
|
|
546
|
+
await ctx.vector.upsert_vectors("knowledge", [{"id": "vec-1", "vector": [0.1, 0.2]}])
|
|
547
|
+
hits = await ctx.vector.search("knowledge", query="invoice policy", top_k=10)
|
|
548
|
+
record = await ctx.vector.get("knowledge", "doc-1")
|
|
549
|
+
await ctx.vector.delete("knowledge", ["doc-1"])
|
|
550
|
+
await ctx.vector.delete_index("knowledge")
|
|
551
|
+
stats = await ctx.vector.stats("knowledge")
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
Blocked Redis control-plane commands include `FLUSHDB`, `FLUSHALL`, `CONFIG`,
|
|
555
|
+
`KEYS`, `CLUSTER`, `SCRIPT`, `EVAL`, `FUNCTION`, and `SELECT`.
|
|
556
|
+
|
|
478
557
|
### `pltt login`
|
|
479
558
|
|
|
480
559
|
Save a Palette sandbox or production environment URL plus token in `~/.palette/config.json` with file mode `0600`. Environment variables still override the stored token when present.
|
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
from palette_sdk.plugin_router import PluginRouter
|
|
4
4
|
from palette_sdk.plugin_context import MissingSecretError, PluginContext, get_plugin_context
|
|
5
5
|
from palette_sdk.data_rooms import DataRoomsClient
|
|
6
|
+
from palette_sdk.platform_services import (
|
|
7
|
+
LocalRedisService,
|
|
8
|
+
LocalVectorService,
|
|
9
|
+
PlatformServiceUnavailable,
|
|
10
|
+
UnavailablePlatformService,
|
|
11
|
+
)
|
|
6
12
|
from palette_sdk.repository import OrgRepository
|
|
7
13
|
from palette_sdk.lifecycle import LifecycleHooks
|
|
8
14
|
from palette_sdk.tool_definition import ToolDefinition
|
|
@@ -36,6 +42,10 @@ __all__ = [
|
|
|
36
42
|
"MissingSecretError",
|
|
37
43
|
"get_plugin_context",
|
|
38
44
|
"DataRoomsClient",
|
|
45
|
+
"LocalRedisService",
|
|
46
|
+
"LocalVectorService",
|
|
47
|
+
"PlatformServiceUnavailable",
|
|
48
|
+
"UnavailablePlatformService",
|
|
39
49
|
"OrgRepository",
|
|
40
50
|
"LifecycleHooks",
|
|
41
51
|
"ToolDefinition",
|
|
@@ -62,4 +72,4 @@ __all__ = [
|
|
|
62
72
|
"route_permission_issues",
|
|
63
73
|
]
|
|
64
74
|
|
|
65
|
-
__version__ = "0.1.
|
|
75
|
+
__version__ = "0.1.7"
|
|
@@ -113,7 +113,7 @@ class PluginManifest(BaseModel):
|
|
|
113
113
|
tools: list[ToolEntry] = Field(default_factory=list)
|
|
114
114
|
permissions: list[str] = Field(default_factory=list)
|
|
115
115
|
secrets: dict[str, SecretSpec] = Field(default_factory=dict)
|
|
116
|
-
platform_services: list[Literal["llm", "
|
|
116
|
+
platform_services: list[Literal["llm", "redis", "storage", "vector"]] | dict[str, PlatformServiceSpec] = Field(default_factory=list)
|
|
117
117
|
rating: float = 0.0
|
|
118
118
|
reviews: int = 0
|
|
119
119
|
featured: bool = False
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Platform-managed services exposed to plugin backends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import math
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PlatformServiceUnavailable(RuntimeError):
|
|
13
|
+
"""Raised when a plugin uses a platform service it did not declare."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UnavailablePlatformService:
|
|
17
|
+
def __init__(self, name: str):
|
|
18
|
+
self.name = name
|
|
19
|
+
|
|
20
|
+
def __getattr__(self, attr: str) -> Any:
|
|
21
|
+
raise PlatformServiceUnavailable(
|
|
22
|
+
f"ctx.{self.name} is unavailable. Declare platform_services: [\"{self.name}\"] in palette-plugin.json."
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class LocalRedisService:
|
|
28
|
+
"""In-memory Redis-style implementation used by pltt dev."""
|
|
29
|
+
|
|
30
|
+
_values: dict[str, tuple[Any, float | None]] = field(default_factory=dict)
|
|
31
|
+
|
|
32
|
+
def _expired(self, key: str) -> bool:
|
|
33
|
+
item = self._values.get(key)
|
|
34
|
+
if item is None:
|
|
35
|
+
return True
|
|
36
|
+
_, expires_at = item
|
|
37
|
+
if expires_at is not None and expires_at <= time.time():
|
|
38
|
+
self._values.pop(key, None)
|
|
39
|
+
return True
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
def _expires_at(self, ttl: int | None = None) -> float | None:
|
|
43
|
+
return time.time() + ttl if ttl and ttl > 0 else None
|
|
44
|
+
|
|
45
|
+
async def get(self, key: str, default: Any = None) -> Any:
|
|
46
|
+
if self._expired(key):
|
|
47
|
+
return default
|
|
48
|
+
return self._values[key][0]
|
|
49
|
+
|
|
50
|
+
def scoped_key(self, key: str) -> str:
|
|
51
|
+
return key
|
|
52
|
+
|
|
53
|
+
def unscoped_key(self, key: str) -> str:
|
|
54
|
+
return key
|
|
55
|
+
|
|
56
|
+
async def execute(self, command: str, *args: Any, **kwargs: Any) -> Any:
|
|
57
|
+
method = getattr(self, command.lower(), None)
|
|
58
|
+
if method is None:
|
|
59
|
+
raise NotImplementedError(f"LocalRedisService does not emulate Redis command {command!r}")
|
|
60
|
+
return await method(*args, **kwargs)
|
|
61
|
+
|
|
62
|
+
async def set(self, key: str, value: Any, ttl: int | None = None, *, nx: bool = False, xx: bool = False) -> bool:
|
|
63
|
+
exists = not self._expired(key)
|
|
64
|
+
if nx and exists:
|
|
65
|
+
return False
|
|
66
|
+
if xx and not exists:
|
|
67
|
+
return False
|
|
68
|
+
self._values[key] = (value, self._expires_at(ttl))
|
|
69
|
+
return True
|
|
70
|
+
|
|
71
|
+
async def delete(self, *keys: str) -> int:
|
|
72
|
+
count = 0
|
|
73
|
+
for key in keys:
|
|
74
|
+
if not self._expired(key) and key in self._values:
|
|
75
|
+
count += 1
|
|
76
|
+
self._values.pop(key, None)
|
|
77
|
+
return count
|
|
78
|
+
|
|
79
|
+
async def exists(self, key: str) -> bool:
|
|
80
|
+
return not self._expired(key)
|
|
81
|
+
|
|
82
|
+
async def expire(self, key: str, ttl: int) -> bool:
|
|
83
|
+
if self._expired(key):
|
|
84
|
+
return False
|
|
85
|
+
self._values[key] = (self._values[key][0], self._expires_at(ttl))
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
async def ttl(self, key: str) -> int:
|
|
89
|
+
if self._expired(key):
|
|
90
|
+
return -2
|
|
91
|
+
expires_at = self._values[key][1]
|
|
92
|
+
if expires_at is None:
|
|
93
|
+
return -1
|
|
94
|
+
return max(0, int(expires_at - time.time()))
|
|
95
|
+
|
|
96
|
+
async def incr(self, key: str, amount: int = 1) -> int:
|
|
97
|
+
value = int(await self.get(key, 0)) + amount
|
|
98
|
+
await self.set(key, value)
|
|
99
|
+
return value
|
|
100
|
+
|
|
101
|
+
async def decr(self, key: str, amount: int = 1) -> int:
|
|
102
|
+
return await self.incr(key, -amount)
|
|
103
|
+
|
|
104
|
+
async def scan(self, prefix: str = "", limit: int = 100) -> list[str]:
|
|
105
|
+
out: list[str] = []
|
|
106
|
+
for key in list(self._values):
|
|
107
|
+
if self._expired(key):
|
|
108
|
+
continue
|
|
109
|
+
if key.startswith(prefix):
|
|
110
|
+
out.append(key)
|
|
111
|
+
if len(out) >= limit:
|
|
112
|
+
break
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
async def hset(self, key: str, field: str, value: Any) -> int:
|
|
116
|
+
data = await self.get(key, {})
|
|
117
|
+
if not isinstance(data, dict):
|
|
118
|
+
data = {}
|
|
119
|
+
created = 0 if field in data else 1
|
|
120
|
+
data[field] = value
|
|
121
|
+
await self.set(key, data)
|
|
122
|
+
return created
|
|
123
|
+
|
|
124
|
+
async def hget(self, key: str, field: str, default: Any = None) -> Any:
|
|
125
|
+
data = await self.get(key, {})
|
|
126
|
+
return data.get(field, default) if isinstance(data, dict) else default
|
|
127
|
+
|
|
128
|
+
async def hgetall(self, key: str) -> dict[str, Any]:
|
|
129
|
+
data = await self.get(key, {})
|
|
130
|
+
return dict(data) if isinstance(data, dict) else {}
|
|
131
|
+
|
|
132
|
+
async def hdel(self, key: str, *fields: str) -> int:
|
|
133
|
+
data = await self.get(key, {})
|
|
134
|
+
if not isinstance(data, dict):
|
|
135
|
+
return 0
|
|
136
|
+
count = 0
|
|
137
|
+
for field in fields:
|
|
138
|
+
if field in data:
|
|
139
|
+
count += 1
|
|
140
|
+
data.pop(field, None)
|
|
141
|
+
await self.set(key, data)
|
|
142
|
+
return count
|
|
143
|
+
|
|
144
|
+
async def lpush(self, key: str, *values: Any) -> int:
|
|
145
|
+
data = await self.get(key, [])
|
|
146
|
+
if not isinstance(data, list):
|
|
147
|
+
data = []
|
|
148
|
+
data = list(values) + data
|
|
149
|
+
await self.set(key, data)
|
|
150
|
+
return len(data)
|
|
151
|
+
|
|
152
|
+
async def rpush(self, key: str, *values: Any) -> int:
|
|
153
|
+
data = await self.get(key, [])
|
|
154
|
+
if not isinstance(data, list):
|
|
155
|
+
data = []
|
|
156
|
+
data.extend(values)
|
|
157
|
+
await self.set(key, data)
|
|
158
|
+
return len(data)
|
|
159
|
+
|
|
160
|
+
async def lpop(self, key: str, default: Any = None) -> Any:
|
|
161
|
+
data = await self.get(key, [])
|
|
162
|
+
if not isinstance(data, list) or not data:
|
|
163
|
+
return default
|
|
164
|
+
value = data.pop(0)
|
|
165
|
+
await self.set(key, data)
|
|
166
|
+
return value
|
|
167
|
+
|
|
168
|
+
async def rpop(self, key: str, default: Any = None) -> Any:
|
|
169
|
+
data = await self.get(key, [])
|
|
170
|
+
if not isinstance(data, list) or not data:
|
|
171
|
+
return default
|
|
172
|
+
value = data.pop()
|
|
173
|
+
await self.set(key, data)
|
|
174
|
+
return value
|
|
175
|
+
|
|
176
|
+
async def lrange(self, key: str, start: int = 0, stop: int = -1) -> list[Any]:
|
|
177
|
+
data = await self.get(key, [])
|
|
178
|
+
if not isinstance(data, list):
|
|
179
|
+
return []
|
|
180
|
+
end = None if stop == -1 else stop + 1
|
|
181
|
+
return data[start:end]
|
|
182
|
+
|
|
183
|
+
async def sadd(self, key: str, *values: Any) -> int:
|
|
184
|
+
data = await self.get(key, [])
|
|
185
|
+
if not isinstance(data, list):
|
|
186
|
+
data = []
|
|
187
|
+
count = 0
|
|
188
|
+
for value in values:
|
|
189
|
+
if value not in data:
|
|
190
|
+
data.append(value)
|
|
191
|
+
count += 1
|
|
192
|
+
await self.set(key, data)
|
|
193
|
+
return count
|
|
194
|
+
|
|
195
|
+
async def srem(self, key: str, *values: Any) -> int:
|
|
196
|
+
data = await self.get(key, [])
|
|
197
|
+
if not isinstance(data, list):
|
|
198
|
+
return 0
|
|
199
|
+
count = 0
|
|
200
|
+
for value in values:
|
|
201
|
+
if value in data:
|
|
202
|
+
data.remove(value)
|
|
203
|
+
count += 1
|
|
204
|
+
await self.set(key, data)
|
|
205
|
+
return count
|
|
206
|
+
|
|
207
|
+
async def smembers(self, key: str) -> list[Any]:
|
|
208
|
+
data = await self.get(key, [])
|
|
209
|
+
return list(data) if isinstance(data, list) else []
|
|
210
|
+
|
|
211
|
+
async def zadd(self, key: str, mapping: dict[str, float]) -> int:
|
|
212
|
+
data = await self.get(key, {})
|
|
213
|
+
if not isinstance(data, dict):
|
|
214
|
+
data = {}
|
|
215
|
+
created = 0
|
|
216
|
+
for member, score in mapping.items():
|
|
217
|
+
if member not in data:
|
|
218
|
+
created += 1
|
|
219
|
+
data[member] = float(score)
|
|
220
|
+
await self.set(key, data)
|
|
221
|
+
return created
|
|
222
|
+
|
|
223
|
+
async def zrange(self, key: str, start: int = 0, stop: int = -1, *, with_scores: bool = False) -> list[Any]:
|
|
224
|
+
data = await self.get(key, {})
|
|
225
|
+
if not isinstance(data, dict):
|
|
226
|
+
return []
|
|
227
|
+
rows = sorted(data.items(), key=lambda item: item[1])
|
|
228
|
+
end = None if stop == -1 else stop + 1
|
|
229
|
+
sliced = rows[start:end]
|
|
230
|
+
return sliced if with_scores else [member for member, _score in sliced]
|
|
231
|
+
|
|
232
|
+
async def zrem(self, key: str, *members: Any) -> int:
|
|
233
|
+
data = await self.get(key, {})
|
|
234
|
+
if not isinstance(data, dict):
|
|
235
|
+
return 0
|
|
236
|
+
count = 0
|
|
237
|
+
for member in members:
|
|
238
|
+
if member in data:
|
|
239
|
+
data.pop(member, None)
|
|
240
|
+
count += 1
|
|
241
|
+
await self.set(key, data)
|
|
242
|
+
return count
|
|
243
|
+
|
|
244
|
+
async def enqueue(self, key: str, value: Any) -> int:
|
|
245
|
+
return await self.rpush(key, value)
|
|
246
|
+
|
|
247
|
+
async def dequeue(self, key: str, default: Any = None) -> Any:
|
|
248
|
+
return await self.lpop(key, default)
|
|
249
|
+
|
|
250
|
+
async def lock(self, key: str, token: str, ttl: int = 30) -> bool:
|
|
251
|
+
return await self.set(f"lock:{key}", token, ttl=ttl, nx=True)
|
|
252
|
+
|
|
253
|
+
async def unlock(self, key: str, token: str) -> bool:
|
|
254
|
+
lock_key = f"lock:{key}"
|
|
255
|
+
if await self.get(lock_key) != token:
|
|
256
|
+
return False
|
|
257
|
+
await self.delete(lock_key)
|
|
258
|
+
return True
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _text_vector(text: str, dimensions: int = 64) -> list[float]:
|
|
262
|
+
vector = [0.0] * dimensions
|
|
263
|
+
for token in text.lower().split():
|
|
264
|
+
digest = hashlib.sha256(token.encode("utf-8")).digest()
|
|
265
|
+
vector[int.from_bytes(digest[:4], "big") % dimensions] += 1.0
|
|
266
|
+
norm = math.sqrt(sum(v * v for v in vector)) or 1.0
|
|
267
|
+
return [v / norm for v in vector]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _cosine(left: list[float], right: list[float]) -> float:
|
|
271
|
+
size = min(len(left), len(right))
|
|
272
|
+
if size == 0:
|
|
273
|
+
return 0.0
|
|
274
|
+
return sum(left[i] * right[i] for i in range(size))
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@dataclass
|
|
278
|
+
class LocalVectorService:
|
|
279
|
+
"""Small in-memory vector store used by pltt dev."""
|
|
280
|
+
|
|
281
|
+
_items: dict[str, dict[str, dict[str, Any]]] = field(default_factory=dict)
|
|
282
|
+
|
|
283
|
+
async def upsert_vectors(self, index: str, items: list[dict[str, Any]]) -> int:
|
|
284
|
+
bucket = self._items.setdefault(index, {})
|
|
285
|
+
for item in items:
|
|
286
|
+
record_id = str(item["id"])
|
|
287
|
+
bucket[record_id] = {
|
|
288
|
+
"id": record_id,
|
|
289
|
+
"vector": item["vector"],
|
|
290
|
+
"text": item.get("text", ""),
|
|
291
|
+
"metadata": item.get("metadata") or {},
|
|
292
|
+
}
|
|
293
|
+
return len(items)
|
|
294
|
+
|
|
295
|
+
async def upsert_texts(self, index: str, items: list[dict[str, Any]]) -> int:
|
|
296
|
+
vector_items = [
|
|
297
|
+
{
|
|
298
|
+
**item,
|
|
299
|
+
"vector": _text_vector(str(item.get("text", ""))),
|
|
300
|
+
}
|
|
301
|
+
for item in items
|
|
302
|
+
]
|
|
303
|
+
return await self.upsert_vectors(index, vector_items)
|
|
304
|
+
|
|
305
|
+
async def search(
|
|
306
|
+
self,
|
|
307
|
+
index: str,
|
|
308
|
+
*,
|
|
309
|
+
query: str | None = None,
|
|
310
|
+
vector: list[float] | None = None,
|
|
311
|
+
top_k: int = 10,
|
|
312
|
+
filter: dict[str, Any] | None = None,
|
|
313
|
+
) -> list[dict[str, Any]]:
|
|
314
|
+
query_vector = vector or _text_vector(query or "")
|
|
315
|
+
rows = []
|
|
316
|
+
for item in self._items.get(index, {}).values():
|
|
317
|
+
metadata = item.get("metadata") or {}
|
|
318
|
+
if filter and any(metadata.get(k) != v for k, v in filter.items()):
|
|
319
|
+
continue
|
|
320
|
+
rows.append({
|
|
321
|
+
"id": item["id"],
|
|
322
|
+
"score": _cosine(query_vector, item["vector"]),
|
|
323
|
+
"text": item.get("text", ""),
|
|
324
|
+
"metadata": metadata,
|
|
325
|
+
})
|
|
326
|
+
rows.sort(key=lambda row: row["score"], reverse=True)
|
|
327
|
+
return rows[: max(1, min(top_k, 100))]
|
|
328
|
+
|
|
329
|
+
async def get(self, index: str, id: str) -> dict[str, Any] | None:
|
|
330
|
+
return self._items.get(index, {}).get(str(id))
|
|
331
|
+
|
|
332
|
+
async def client(self):
|
|
333
|
+
return self
|
|
334
|
+
|
|
335
|
+
def collection_name(self) -> str:
|
|
336
|
+
return "local"
|
|
337
|
+
|
|
338
|
+
def point_id(self, index: str, id: str) -> str:
|
|
339
|
+
return f"{index}:{id}"
|
|
340
|
+
|
|
341
|
+
def scoped_filter(self, index: str, extra: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
342
|
+
return {"index": index, **(extra or {})}
|
|
343
|
+
|
|
344
|
+
def merge_filter(self, index: str, query_filter: Any | None = None) -> Any:
|
|
345
|
+
return query_filter or self.scoped_filter(index)
|
|
346
|
+
|
|
347
|
+
def scoped_payload(self, index: str, id: str, *, text: str | None = None, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
348
|
+
return {"index": index, "record_id": str(id), "text": text or "", "metadata": metadata or {}}
|
|
349
|
+
|
|
350
|
+
def scoped_point(
|
|
351
|
+
self,
|
|
352
|
+
index: str,
|
|
353
|
+
*,
|
|
354
|
+
id: str,
|
|
355
|
+
vector: list[float],
|
|
356
|
+
text: str | None = None,
|
|
357
|
+
metadata: dict[str, Any] | None = None,
|
|
358
|
+
) -> dict[str, Any]:
|
|
359
|
+
return {"id": self.point_id(index, id), "vector": vector, "payload": self.scoped_payload(index, id, text=text, metadata=metadata)}
|
|
360
|
+
|
|
361
|
+
async def delete(self, index: str, ids: list[str]) -> int:
|
|
362
|
+
bucket = self._items.get(index, {})
|
|
363
|
+
count = 0
|
|
364
|
+
for record_id in ids:
|
|
365
|
+
if bucket.pop(str(record_id), None) is not None:
|
|
366
|
+
count += 1
|
|
367
|
+
return count
|
|
368
|
+
|
|
369
|
+
async def delete_index(self, index: str) -> int:
|
|
370
|
+
count = len(self._items.get(index, {}))
|
|
371
|
+
self._items.pop(index, None)
|
|
372
|
+
return count
|
|
373
|
+
|
|
374
|
+
async def stats(self, index: str | None = None) -> dict[str, Any]:
|
|
375
|
+
if index:
|
|
376
|
+
return {"index": index, "count": len(self._items.get(index, {}))}
|
|
377
|
+
return {"indexes": {name: len(items) for name, items in self._items.items()}}
|
|
@@ -11,6 +11,7 @@ from fastapi import Depends, Request
|
|
|
11
11
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
12
12
|
|
|
13
13
|
from palette_sdk.data_rooms import DataRoomsClient
|
|
14
|
+
from palette_sdk.platform_services import UnavailablePlatformService
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
class MissingSecretError(KeyError):
|
|
@@ -43,6 +44,8 @@ class PluginContext:
|
|
|
43
44
|
permissions: list[str] = field(default_factory=list)
|
|
44
45
|
storage: Any = None # Platform storage service injected at runtime
|
|
45
46
|
data_rooms: DataRoomsClient = field(default_factory=DataRoomsClient)
|
|
47
|
+
redis: Any = field(default_factory=lambda: UnavailablePlatformService("redis"))
|
|
48
|
+
vector: Any = field(default_factory=lambda: UnavailablePlatformService("vector"))
|
|
46
49
|
config: dict[str, Any] = field(default_factory=dict)
|
|
47
50
|
logger: logging.Logger = field(default_factory=lambda: logging.getLogger("palette_sdk.plugin"))
|
|
48
51
|
|
|
@@ -107,6 +110,8 @@ async def get_plugin_context(request: Request) -> PluginContext:
|
|
|
107
110
|
permissions=getattr(state, "plugin_permissions", []),
|
|
108
111
|
storage=getattr(state, "storage", None),
|
|
109
112
|
data_rooms=DataRoomsClient(getattr(state, "data_rooms", None)),
|
|
113
|
+
redis=getattr(state, "redis", None) or UnavailablePlatformService("redis"),
|
|
114
|
+
vector=getattr(state, "vector", None) or UnavailablePlatformService("vector"),
|
|
110
115
|
config=getattr(state, "plugin_config", {}),
|
|
111
116
|
logger=getattr(state, "plugin_logger", logging.getLogger(f"palette_sdk.plugin.{getattr(state, 'plugin_id', 'unknown')}")),
|
|
112
117
|
)
|
|
@@ -112,6 +112,8 @@ Available context values:
|
|
|
112
112
|
| `ctx.permissions` | Permissions granted from `palette-plugin.json` |
|
|
113
113
|
| `ctx.storage` | Runtime storage service, when available |
|
|
114
114
|
| `ctx.data_rooms` | Backend Data Room client |
|
|
115
|
+
| `ctx.redis` | Plugin/org-scoped Redis-style service when `platform_services` includes `redis` |
|
|
116
|
+
| `ctx.vector` | Plugin/org-scoped vector service when `platform_services` includes `vector` |
|
|
115
117
|
| `ctx.config` | App install/config values |
|
|
116
118
|
| `ctx.logger` | Runtime logger for backend code |
|
|
117
119
|
| `ctx.repo(Model)` | Org-safe CRUD helper for app-owned tables |
|
|
@@ -232,7 +234,16 @@ organization can only access its own rows.
|
|
|
232
234
|
|
|
233
235
|
## 7. Repository CRUD
|
|
234
236
|
|
|
235
|
-
Use `ctx.
|
|
237
|
+
Use `ctx.db` for the full database feature set. It is the real SQLAlchemy
|
|
238
|
+
`AsyncSession`, scoped by Palette before your route runs, so you can use normal
|
|
239
|
+
SQLAlchemy Core/ORM operations inside your app schema: `select`, `insert`,
|
|
240
|
+
`update`, `delete`, joins, aggregates, relationships, transactions, bulk
|
|
241
|
+
operations, and raw `text()` SQL. The production runtime sets the app schema,
|
|
242
|
+
uses a low-privilege runtime role, and applies org row-level security; local dev
|
|
243
|
+
uses the same SDK shape against the plugin-local database.
|
|
244
|
+
|
|
245
|
+
Use `ctx.repo(Model)` when you want a short convenience helper for simple CRUD
|
|
246
|
+
instead of writing repeated org filters.
|
|
236
247
|
|
|
237
248
|
```python
|
|
238
249
|
from fastapi import Depends, HTTPException
|
|
@@ -309,6 +320,76 @@ await ctx.repo(Model).update(id, **values)
|
|
|
309
320
|
await ctx.repo(Model).delete(id)
|
|
310
321
|
```
|
|
311
322
|
|
|
323
|
+
For anything outside that helper, use `ctx.db` directly:
|
|
324
|
+
|
|
325
|
+
```python
|
|
326
|
+
from sqlalchemy import select, update, func, text
|
|
327
|
+
|
|
328
|
+
total = await ctx.db.scalar(select(func.count()).select_from(Invoice))
|
|
329
|
+
|
|
330
|
+
await ctx.db.execute(
|
|
331
|
+
update(Invoice)
|
|
332
|
+
.where(Invoice.status == "draft")
|
|
333
|
+
.values(status="queued")
|
|
334
|
+
)
|
|
335
|
+
await ctx.db.commit()
|
|
336
|
+
|
|
337
|
+
rows = (
|
|
338
|
+
await ctx.db.execute(text("select id, customer_name from finance_tools__invoices"))
|
|
339
|
+
).mappings().all()
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
Create or change tables, indexes, constraints, and other schema objects through
|
|
343
|
+
Alembic migrations. Route code intentionally has data permissions only; it
|
|
344
|
+
cannot access Palette core tables, another app's schema, or another org's
|
|
345
|
+
RLS-protected rows.
|
|
346
|
+
|
|
347
|
+
Common `ctx.db` operations:
|
|
348
|
+
|
|
349
|
+
```python
|
|
350
|
+
from sqlalchemy import delete, func, insert, select, text, update
|
|
351
|
+
|
|
352
|
+
rows = (await ctx.db.execute(select(Invoice))).scalars().all()
|
|
353
|
+
invoice = await ctx.db.scalar(select(Invoice).where(Invoice.id == invoice_id))
|
|
354
|
+
count = await ctx.db.scalar(select(func.count()).select_from(Invoice))
|
|
355
|
+
|
|
356
|
+
ctx.db.add(Invoice(customer_name="A", amount=10, status="draft"))
|
|
357
|
+
ctx.db.add_all([
|
|
358
|
+
Invoice(customer_name="B", amount=20, status="draft"),
|
|
359
|
+
Invoice(customer_name="C", amount=30, status="draft"),
|
|
360
|
+
])
|
|
361
|
+
await ctx.db.execute(insert(Invoice).values(customer_name="D", amount=40, status="draft"))
|
|
362
|
+
await ctx.db.execute(update(Invoice).where(Invoice.id == invoice_id).values(status="paid"))
|
|
363
|
+
await ctx.db.execute(delete(Invoice).where(Invoice.id == invoice_id))
|
|
364
|
+
|
|
365
|
+
async with ctx.db.begin():
|
|
366
|
+
ctx.db.add(Invoice(customer_name="E", amount=50, status="queued"))
|
|
367
|
+
|
|
368
|
+
await ctx.db.commit()
|
|
369
|
+
await ctx.db.rollback()
|
|
370
|
+
await ctx.db.refresh(invoice)
|
|
371
|
+
|
|
372
|
+
result = await ctx.db.execute(
|
|
373
|
+
text("select id, customer_name from finance_tools__invoices where amount > :amount"),
|
|
374
|
+
{"amount": 100},
|
|
375
|
+
)
|
|
376
|
+
rows = result.mappings().all()
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
Common migration operations:
|
|
380
|
+
|
|
381
|
+
```python
|
|
382
|
+
op.create_table(...)
|
|
383
|
+
op.add_column(...)
|
|
384
|
+
op.alter_column(...)
|
|
385
|
+
op.create_index(...)
|
|
386
|
+
op.create_unique_constraint(...)
|
|
387
|
+
op.create_foreign_key(...)
|
|
388
|
+
op.drop_index(...)
|
|
389
|
+
op.drop_column(...)
|
|
390
|
+
ensure_org_rls(op, "finance_tools__invoices")
|
|
391
|
+
```
|
|
392
|
+
|
|
312
393
|
## 8. Data Rooms From Python
|
|
313
394
|
|
|
314
395
|
Use `ctx.data_rooms` when backend code needs to create folders, find files, or
|
|
@@ -505,6 +586,122 @@ install config, plugin-scope encrypted secrets, or local `.palette/.env.local`
|
|
|
505
586
|
during `pltt dev`. Undeclared keys still fall back to the process environment
|
|
506
587
|
for local compatibility.
|
|
507
588
|
|
|
589
|
+
Managed Redis and vector services are declared in the manifest:
|
|
590
|
+
|
|
591
|
+
```json
|
|
592
|
+
{
|
|
593
|
+
"platform_services": ["redis", "vector"]
|
|
594
|
+
}
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
```python
|
|
598
|
+
await ctx.redis.set("cache:summary", {"ready": True}, ttl=600)
|
|
599
|
+
value = await ctx.redis.get("cache:summary")
|
|
600
|
+
|
|
601
|
+
await ctx.vector.upsert_texts(
|
|
602
|
+
"knowledge",
|
|
603
|
+
[{"id": "doc-1", "text": "Palette apps are isolated.", "metadata": {"type": "note"}}],
|
|
604
|
+
)
|
|
605
|
+
hits = await ctx.vector.search("knowledge", query="app isolation", top_k=3)
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
Palette scopes every Redis key and vector operation by `plugin_id` and
|
|
609
|
+
`organization_id`; hosted previews also include the publish id. Plugin code
|
|
610
|
+
cannot read, list, update, or delete records owned by another app or org.
|
|
611
|
+
|
|
612
|
+
Advanced provider features are still available through scoped helpers:
|
|
613
|
+
`ctx.redis.execute(...)` forwards Redis data-plane commands after key rewriting,
|
|
614
|
+
while blocking server/admin commands. `ctx.vector.client()` returns the Qdrant
|
|
615
|
+
client for custom calls; combine it with `collection_name()`, `scoped_filter()`,
|
|
616
|
+
`merge_filter()`, `scoped_payload()`, and `scoped_point()` to preserve isolation.
|
|
617
|
+
|
|
618
|
+
Common `ctx.redis` commands:
|
|
619
|
+
|
|
620
|
+
```python
|
|
621
|
+
await ctx.redis.get("key", default=None)
|
|
622
|
+
await ctx.redis.set("key", {"json": True}, ttl=600, nx=False, xx=False)
|
|
623
|
+
await ctx.redis.delete("key1", "key2")
|
|
624
|
+
await ctx.redis.exists("key")
|
|
625
|
+
await ctx.redis.expire("key", 300)
|
|
626
|
+
await ctx.redis.ttl("key")
|
|
627
|
+
await ctx.redis.incr("counter", 1)
|
|
628
|
+
await ctx.redis.decr("counter", 1)
|
|
629
|
+
await ctx.redis.scan(prefix="cache:", limit=100)
|
|
630
|
+
|
|
631
|
+
await ctx.redis.hset("hash", "field", {"value": 1})
|
|
632
|
+
await ctx.redis.hget("hash", "field")
|
|
633
|
+
await ctx.redis.hgetall("hash")
|
|
634
|
+
await ctx.redis.hdel("hash", "field")
|
|
635
|
+
|
|
636
|
+
await ctx.redis.lpush("queue", {"job": 1})
|
|
637
|
+
await ctx.redis.rpush("queue", {"job": 2})
|
|
638
|
+
await ctx.redis.lpop("queue")
|
|
639
|
+
await ctx.redis.rpop("queue")
|
|
640
|
+
await ctx.redis.lrange("queue", 0, -1)
|
|
641
|
+
|
|
642
|
+
await ctx.redis.sadd("tags", "red", "blue")
|
|
643
|
+
await ctx.redis.smembers("tags")
|
|
644
|
+
await ctx.redis.srem("tags", "red")
|
|
645
|
+
|
|
646
|
+
await ctx.redis.zadd("scores", {"alice": 10, "bob": 8})
|
|
647
|
+
await ctx.redis.zrange("scores", 0, -1, with_scores=True)
|
|
648
|
+
await ctx.redis.zrem("scores", "bob")
|
|
649
|
+
|
|
650
|
+
await ctx.redis.enqueue("jobs", {"task": "sync"})
|
|
651
|
+
await ctx.redis.dequeue("jobs")
|
|
652
|
+
await ctx.redis.xadd("events", {"type": "created"})
|
|
653
|
+
await ctx.redis.xread({"events": "0-0"}, count=10)
|
|
654
|
+
await ctx.redis.lock("invoice:1", token, ttl=30)
|
|
655
|
+
await ctx.redis.unlock("invoice:1", token)
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
Provider-style Redis commands:
|
|
659
|
+
|
|
660
|
+
```python
|
|
661
|
+
await ctx.redis.execute("MSET", "a", "1", "b", "2")
|
|
662
|
+
values = await ctx.redis.execute("MGET", "a", "b")
|
|
663
|
+
await ctx.redis.execute("ZUNIONSTORE", "dest", 2, "scores:1", "scores:2")
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
Server/admin Redis commands are blocked because they would affect the shared
|
|
667
|
+
central Redis instance: `FLUSHDB`, `FLUSHALL`, `CONFIG`, `KEYS`, `CLUSTER`,
|
|
668
|
+
`SCRIPT`, `EVAL`, `FUNCTION`, `SELECT`, and similar control-plane commands.
|
|
669
|
+
|
|
670
|
+
Common `ctx.vector` commands:
|
|
671
|
+
|
|
672
|
+
```python
|
|
673
|
+
await ctx.vector.upsert_texts(
|
|
674
|
+
"knowledge",
|
|
675
|
+
[{"id": "doc-1", "text": "Text to embed", "metadata": {"type": "note"}}],
|
|
676
|
+
)
|
|
677
|
+
await ctx.vector.upsert_vectors(
|
|
678
|
+
"knowledge",
|
|
679
|
+
[{"id": "vec-1", "vector": [0.1, 0.2], "metadata": {"type": "manual"}}],
|
|
680
|
+
)
|
|
681
|
+
hits = await ctx.vector.search("knowledge", query="invoice policy", top_k=10)
|
|
682
|
+
hits = await ctx.vector.search("knowledge", vector=[0.1, 0.2], filter={"type": "note"})
|
|
683
|
+
record = await ctx.vector.get("knowledge", "doc-1")
|
|
684
|
+
await ctx.vector.delete("knowledge", ["doc-1"])
|
|
685
|
+
await ctx.vector.delete_index("knowledge")
|
|
686
|
+
stats = await ctx.vector.stats("knowledge")
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
Advanced Qdrant calls should use scoped helpers:
|
|
690
|
+
|
|
691
|
+
```python
|
|
692
|
+
client = await ctx.vector.client()
|
|
693
|
+
collection = ctx.vector.collection_name()
|
|
694
|
+
query_filter = ctx.vector.scoped_filter("knowledge", {"type": "note"})
|
|
695
|
+
point = ctx.vector.scoped_point(
|
|
696
|
+
"knowledge",
|
|
697
|
+
id="doc-2",
|
|
698
|
+
vector=[0.1, 0.2],
|
|
699
|
+
text="Indexed text",
|
|
700
|
+
metadata={"type": "note"},
|
|
701
|
+
)
|
|
702
|
+
await client.upsert(collection_name=collection, points=[point])
|
|
703
|
+
```
|
|
704
|
+
|
|
508
705
|
## 10. Lifecycle Hooks
|
|
509
706
|
|
|
510
707
|
Lifecycle hooks let an app seed defaults or clean up app-owned data when the
|
package/lib/dev-simulator.js
CHANGED
|
@@ -119,11 +119,28 @@ DATABASE_ENABLED = bool(MANIFEST.get("database") or MANIFEST.get("capabilities",
|
|
|
119
119
|
DATABASE_URL = os.environ.get("PALETTE_DEV_DATABASE_URL", "sqlite+aiosqlite:///${databasePath.replace(/\\/g, "/")}")
|
|
120
120
|
DEV_SECRETS = json.loads(${JSON.stringify(JSON.stringify(devSecrets))})
|
|
121
121
|
|
|
122
|
+
def _service_enabled(name: str) -> bool:
|
|
123
|
+
services = MANIFEST.get("platform_services") or []
|
|
124
|
+
if isinstance(services, list):
|
|
125
|
+
return name in services
|
|
126
|
+
if isinstance(services, dict):
|
|
127
|
+
return name in services
|
|
128
|
+
return False
|
|
129
|
+
|
|
122
130
|
if SDK_PATH:
|
|
123
131
|
sys.path.insert(0, SDK_PATH)
|
|
124
132
|
sys.path.insert(0, str(ROOT))
|
|
125
133
|
sys.path.insert(0, str(ENTRY.parent))
|
|
126
134
|
|
|
135
|
+
DEV_REDIS = None
|
|
136
|
+
DEV_VECTOR = None
|
|
137
|
+
if _service_enabled("redis"):
|
|
138
|
+
from palette_sdk.platform_services import LocalRedisService
|
|
139
|
+
DEV_REDIS = LocalRedisService()
|
|
140
|
+
if _service_enabled("vector"):
|
|
141
|
+
from palette_sdk.platform_services import LocalVectorService
|
|
142
|
+
DEV_VECTOR = LocalVectorService()
|
|
143
|
+
|
|
127
144
|
spec = importlib.util.spec_from_file_location("palette_local_backend", ENTRY)
|
|
128
145
|
module = importlib.util.module_from_spec(spec)
|
|
129
146
|
assert spec and spec.loader
|
|
@@ -162,6 +179,10 @@ class DevPluginContextMiddleware(BaseHTTPMiddleware):
|
|
|
162
179
|
"secret_scope": "dev",
|
|
163
180
|
}
|
|
164
181
|
request.state.storage = None
|
|
182
|
+
if DEV_REDIS is not None:
|
|
183
|
+
request.state.redis = DEV_REDIS
|
|
184
|
+
if DEV_VECTOR is not None:
|
|
185
|
+
request.state.vector = DEV_VECTOR
|
|
165
186
|
if SessionLocal is None:
|
|
166
187
|
request.state.db = None
|
|
167
188
|
return await call_next(request)
|
package/lib/manifest.js
CHANGED
|
@@ -146,7 +146,7 @@ function validateSecrets(value, errors) {
|
|
|
146
146
|
|
|
147
147
|
function validatePlatformServices(value, errors) {
|
|
148
148
|
if (value === undefined) return
|
|
149
|
-
const known = new Set(["llm", "
|
|
149
|
+
const known = new Set(["llm", "redis", "storage", "vector"])
|
|
150
150
|
if (Array.isArray(value)) {
|
|
151
151
|
for (const service of value) {
|
|
152
152
|
if (!known.has(service)) errors.push(`platform_services contains unknown service: ${service}`)
|