Compare commits

...
Author SHA1 Message Date
Jonathan M HsiehandClaude Opus 5 b00c6d4001 feat(secrets): address Secrets by namespace path
A Secret is identified by a namespace path plus a name, and resolution is
exact: one under `["prod"]` is not visible from `["prod", "vision"]` and never
falls back to a parent.

The binding names it as a `SecretReference` of `{name, namespace_path}` rather
than one joined string. A joined id needs a delimiter, and a delimiter has to be
excluded from every name and every segment forever, agreed on by both sides, and
re-agreed each time either side grows a new way to be configured --
`ClientConfig.id_delimiter` is exactly that, configurable and advertised on
every request, so an id joined on a fixed `$` would contradict a delimiter the
same request declares. Naming the parts costs one object and settles it.

Every verb takes `namespace_path` keyword-only, defaulting to the root, and so
does `EnvVarSecret`, which pins the path at construction -- a worker resolves
the Secret it was handed rather than re-resolving against its own default.

A root path is omitted from the request body rather than sent empty, so a root
request is byte-identical to one from a client that predates this, and a root
binding to one written before namespaces existed. Tests pin both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfmeJ533rQDnPBkMtjerV6
2026-09-11 20:34:53 +00:00
18 changed files with 480 additions and 112 deletions
+11 -5
View File
@@ -153,11 +153,17 @@ class Connection(object):
async def get_function(self, name: str, version: str) -> str: ... async def get_function(self, name: str, version: str) -> str: ...
async def list_functions(self) -> List[str]: ... async def list_functions(self) -> List[str]: ...
async def drop_function(self, name: str, version: str) -> bool: ... async def drop_function(self, name: str, version: str) -> bool: ...
async def create_secret(self, name: str, value: str) -> None: ... async def create_secret(
async def alter_secret(self, name: str, value: str) -> None: ... self, name: str, value: str, namespace_path: List[str]
async def list_secrets(self) -> List[str]: ... ) -> None: ...
async def drop_secret(self, name: str) -> None: ... async def alter_secret(
async def describe_secret(self, name: str) -> Dict[str, str]: ... self, name: str, value: str, namespace_path: List[str]
) -> None: ...
async def list_secrets(self, namespace_path: List[str]) -> List[str]: ...
async def drop_secret(self, name: str, namespace_path: List[str]) -> None: ...
async def describe_secret(
self, name: str, namespace_path: List[str]
) -> Dict[str, str]: ...
async def list_jobs(self) -> List[JobInfo]: ... async def list_jobs(self) -> List[JobInfo]: ...
async def cancel_job(self, job_id: str) -> bool: ... async def cancel_job(self, job_id: str) -> bool: ...
async def execute_query_async( async def execute_query_async(
+70 -26
View File
@@ -58,7 +58,12 @@ from .materialized_view import (
SelectArg, SelectArg,
normalize_select, normalize_select,
) )
from .secrets import EnvVarSecret, SecretInfo, validate_secret_name from .secrets import (
EnvVarSecret,
SecretInfo,
validate_namespace_path,
validate_secret_name,
)
from .table import ( from .table import (
AsyncTable, AsyncTable,
LanceTable, LanceTable,
@@ -779,7 +784,9 @@ class DBConnection(EnforceOverrides):
"Function catalog operations are not supported for this connection type" "Function catalog operations are not supported for this connection type"
) )
def create_secret(self, name: str, value: str) -> None: def create_secret(
self, name: str, value: str, *, namespace_path: Optional[List[str]] = None
) -> None:
"""Create a named Secret in this database. """Create a named Secret in this database.
Fails if the name is taken, so a create never silently becomes a Fails if the name is taken, so a create never silently becomes a
@@ -791,7 +798,9 @@ class DBConnection(EnforceOverrides):
"Secret operations are not supported for this connection type" "Secret operations are not supported for this connection type"
) )
def alter_secret(self, name: str, value: str) -> None: def alter_secret(
self, name: str, value: str, *, namespace_path: Optional[List[str]] = None
) -> None:
"""Replace the credential behind an existing Secret. """Replace the credential behind an existing Secret.
Fails if it does not exist. Every Function bound to the Secret uses the Fails if it does not exist. Every Function bound to the Secret uses the
@@ -803,7 +812,7 @@ class DBConnection(EnforceOverrides):
"Secret operations are not supported for this connection type" "Secret operations are not supported for this connection type"
) )
def list_secrets(self) -> List[str]: def list_secrets(self, *, namespace_path: Optional[List[str]] = None) -> List[str]:
"""The names of every Secret in this database. """The names of every Secret in this database.
Names only. No method returns a stored credential, by construction Names only. No method returns a stored credential, by construction
@@ -813,7 +822,9 @@ class DBConnection(EnforceOverrides):
"Secret operations are not supported for this connection type" "Secret operations are not supported for this connection type"
) )
def drop_secret(self, name: str) -> None: def drop_secret(
self, name: str, *, namespace_path: Optional[List[str]] = None
) -> None:
"""Drop a Secret. """Drop a Secret.
Functions bound to it fail at their next job, naming the Secret; that Functions bound to it fail at their next job, naming the Secret; that
@@ -825,7 +836,9 @@ class DBConnection(EnforceOverrides):
"Secret operations are not supported for this connection type" "Secret operations are not supported for this connection type"
) )
def describe_secret(self, name: str) -> SecretInfo: def describe_secret(
self, name: str, *, namespace_path: Optional[List[str]] = None
) -> SecretInfo:
"""What this database records about a Secret: name and timestamps. """What this database records about a Secret: name and timestamps.
Never the value -- there is no code path that could return one. Local Never the value -- there is no code path that could return one. Local
@@ -1569,24 +1582,32 @@ class LanceDBConnection(DBConnection):
return LOOP.run(self._conn.drop_function(name, version=version)) return LOOP.run(self._conn.drop_function(name, version=version))
@override @override
def create_secret(self, name: str, value: str) -> None: def create_secret(
LOOP.run(self._conn.create_secret(name, value)) self, name: str, value: str, *, namespace_path: Optional[List[str]] = None
) -> None:
LOOP.run(self._conn.create_secret(name, value, namespace_path=namespace_path))
@override @override
def alter_secret(self, name: str, value: str) -> None: def alter_secret(
LOOP.run(self._conn.alter_secret(name, value)) self, name: str, value: str, *, namespace_path: Optional[List[str]] = None
) -> None:
LOOP.run(self._conn.alter_secret(name, value, namespace_path=namespace_path))
@override @override
def list_secrets(self) -> List[str]: def list_secrets(self, *, namespace_path: Optional[List[str]] = None) -> List[str]:
return LOOP.run(self._conn.list_secrets()) return LOOP.run(self._conn.list_secrets(namespace_path=namespace_path))
@override @override
def drop_secret(self, name: str) -> None: def drop_secret(
LOOP.run(self._conn.drop_secret(name)) self, name: str, *, namespace_path: Optional[List[str]] = None
) -> None:
LOOP.run(self._conn.drop_secret(name, namespace_path=namespace_path))
@override @override
def describe_secret(self, name: str) -> SecretInfo: def describe_secret(
return LOOP.run(self._conn.describe_secret(name)) self, name: str, *, namespace_path: Optional[List[str]] = None
) -> SecretInfo:
return LOOP.run(self._conn.describe_secret(name, namespace_path=namespace_path))
@override @override
def list_jobs(self) -> List[JobInfo]: def list_jobs(self) -> List[JobInfo]:
@@ -2418,34 +2439,57 @@ class AsyncConnection(object):
"""Drop one exact immutable Function version from the remote catalog.""" """Drop one exact immutable Function version from the remote catalog."""
return await self._inner.drop_function(name, version) return await self._inner.drop_function(name, version)
async def create_secret(self, name: str, value: str) -> None: async def create_secret(
self, name: str, value: str, *, namespace_path: Optional[List[str]] = None
) -> None:
"""Create a named Secret in this database. """Create a named Secret in this database.
Fails if the name is taken, so a create never silently becomes a Fails if the name is taken, so a create never silently becomes a
rotation. Nothing reads the value back. rotation. Nothing reads the value back.
""" """
await self._inner.create_secret(validate_secret_name(name), value) await self._inner.create_secret(
validate_secret_name(name),
value,
list(validate_namespace_path(namespace_path)),
)
async def alter_secret(self, name: str, value: str) -> None: async def alter_secret(
self, name: str, value: str, *, namespace_path: Optional[List[str]] = None
) -> None:
"""Replace the credential behind an existing Secret. """Replace the credential behind an existing Secret.
Fails if it does not exist. Bound Functions use the new value from Fails if it does not exist. Bound Functions use the new value from
their next job, with no new Function version. their next job, with no new Function version.
""" """
await self._inner.alter_secret(validate_secret_name(name), value) await self._inner.alter_secret(
validate_secret_name(name),
value,
list(validate_namespace_path(namespace_path)),
)
async def list_secrets(self) -> List[str]: async def list_secrets(
self, *, namespace_path: Optional[List[str]] = None
) -> List[str]:
"""The names of every Secret in this database. Names only.""" """The names of every Secret in this database. Names only."""
return await self._inner.list_secrets() return await self._inner.list_secrets(
list(validate_namespace_path(namespace_path))
)
async def drop_secret(self, name: str) -> None: async def drop_secret(
self, name: str, *, namespace_path: Optional[List[str]] = None
) -> None:
"""Drop a Secret. Bound Functions fail at their next job.""" """Drop a Secret. Bound Functions fail at their next job."""
await self._inner.drop_secret(validate_secret_name(name)) await self._inner.drop_secret(
validate_secret_name(name), list(validate_namespace_path(namespace_path))
)
async def describe_secret(self, name: str) -> SecretInfo: async def describe_secret(
self, name: str, *, namespace_path: Optional[List[str]] = None
) -> SecretInfo:
"""What this database records about a Secret. Never the value.""" """What this database records about a Secret. Never the value."""
name, created_at_millis, updated_at_millis = await self._inner.describe_secret( name, created_at_millis, updated_at_millis = await self._inner.describe_secret(
validate_secret_name(name) validate_secret_name(name),
list(validate_namespace_path(namespace_path)),
) )
return SecretInfo( return SecretInfo(
name=name, name=name,
+18 -2
View File
@@ -227,6 +227,19 @@ class FunctionOutput(_OpenRemoteValue):
fields: tuple[FunctionResultField, ...] = () fields: tuple[FunctionResultField, ...] = ()
class SecretReference(_RemoteValue):
"""Where a Secret lives, carried as its parts rather than as one string.
A joined id would need a delimiter, and a delimiter has to be excluded from
every name and segment forever, agreed on by both sides, and re-agreed each
time either grows a new way to be configured. Naming the parts settles all
of that: nothing here is parsed, so nothing can parse two ways.
"""
name: str
namespace_path: tuple[str, ...] = ()
class SecretBinding(_RemoteValue): class SecretBinding(_RemoteValue):
"""How a Secret reaches the Function that binds it. """How a Secret reaches the Function that binds it.
@@ -238,7 +251,7 @@ class SecretBinding(_RemoteValue):
kind: str kind: str
variable: Optional[str] = None variable: Optional[str] = None
secret_ref: Optional[str] = None secret_ref: Optional[SecretReference] = None
class FunctionSignature(_RemoteValue): class FunctionSignature(_RemoteValue):
@@ -1344,7 +1357,10 @@ class UdfDefinition:
SecretBinding( SecretBinding(
kind="env", kind="env",
variable=binding.env_variable, variable=binding.env_variable,
secret_ref=binding.secret, secret_ref=SecretReference(
name=binding.secret,
namespace_path=tuple(binding.namespace_path),
),
) )
for binding in bindings for binding in bindings
), ),
+18 -10
View File
@@ -778,24 +778,32 @@ class RemoteDBConnection(DBConnection):
return LOOP.run(self._conn.drop_function(name, version=version)) return LOOP.run(self._conn.drop_function(name, version=version))
@override @override
def create_secret(self, name: str, value: str) -> None: def create_secret(
LOOP.run(self._conn.create_secret(name, value)) self, name: str, value: str, *, namespace_path: Optional[List[str]] = None
) -> None:
LOOP.run(self._conn.create_secret(name, value, namespace_path=namespace_path))
@override @override
def alter_secret(self, name: str, value: str) -> None: def alter_secret(
LOOP.run(self._conn.alter_secret(name, value)) self, name: str, value: str, *, namespace_path: Optional[List[str]] = None
) -> None:
LOOP.run(self._conn.alter_secret(name, value, namespace_path=namespace_path))
@override @override
def describe_secret(self, name: str) -> SecretInfo: def describe_secret(
return LOOP.run(self._conn.describe_secret(name)) self, name: str, *, namespace_path: Optional[List[str]] = None
) -> SecretInfo:
return LOOP.run(self._conn.describe_secret(name, namespace_path=namespace_path))
@override @override
def list_secrets(self) -> List[str]: def list_secrets(self, *, namespace_path: Optional[List[str]] = None) -> List[str]:
return LOOP.run(self._conn.list_secrets()) return LOOP.run(self._conn.list_secrets(namespace_path=namespace_path))
@override @override
def drop_secret(self, name: str) -> None: def drop_secret(
LOOP.run(self._conn.drop_secret(name)) self, name: str, *, namespace_path: Optional[List[str]] = None
) -> None:
LOOP.run(self._conn.drop_secret(name, namespace_path=namespace_path))
@override @override
def list_jobs(self) -> List["JobInfo"]: def list_jobs(self) -> List["JobInfo"]:
+43 -4
View File
@@ -31,6 +31,31 @@ def validate_secret_name(name: str) -> str:
return name return name
def validate_namespace_path(namespace_path=None):
"""Check a namespace path locally and return it as a tuple.
``None`` and ``[]`` both mean the root namespace. Segments follow the same
rule as Secret names: a binding carries the path and the name as separate
fields, so neither is ever parsed out of the other.
"""
if namespace_path is None:
return ()
if isinstance(namespace_path, str):
raise TypeError(
"namespace_path must be a list of segments, not a string; "
f"did you mean [{namespace_path!r}]?"
)
segments = tuple(namespace_path)
for segment in segments:
if not isinstance(segment, str):
raise TypeError(
f"namespace path segment must be a string, not {type(segment).__name__}"
)
if not _SECRET_NAME.fullmatch(segment):
raise ValueError(f"invalid namespace path segment: {segment!r}")
return segments
def validate_env_variable(name: str) -> str: def validate_env_variable(name: str) -> str:
"""Check an environment variable name locally and return it unchanged.""" """Check an environment variable name locally and return it unchanged."""
if not isinstance(name, str): if not isinstance(name, str):
@@ -76,11 +101,12 @@ class EnvVarSecret:
('openai-prod', 'OPENAI_API_KEY') ('openai-prod', 'OPENAI_API_KEY')
""" """
__slots__ = ("_secret", "_env_variable") __slots__ = ("_secret", "_env_variable", "_namespace_path")
def __init__(self, secret: str, env_variable: str): def __init__(self, secret: str, env_variable: str, *, namespace_path=None):
self._secret = validate_secret_name(secret) self._secret = validate_secret_name(secret)
self._env_variable = validate_env_variable(env_variable) self._env_variable = validate_env_variable(env_variable)
self._namespace_path = validate_namespace_path(namespace_path)
@property @property
def secret(self) -> str: def secret(self) -> str:
@@ -92,10 +118,20 @@ class EnvVarSecret:
"""The environment variable the value is delivered in.""" """The environment variable the value is delivered in."""
return self._env_variable return self._env_variable
@property
def namespace_path(self):
"""The namespace path the Secret is addressed within, root when empty."""
return list(self._namespace_path)
def __repr__(self) -> str: def __repr__(self) -> str:
path = (
f", namespace_path={list(self._namespace_path)!r}"
if self._namespace_path
else ""
)
return ( return (
f"EnvVarSecret(secret={self._secret!r}, " f"EnvVarSecret(secret={self._secret!r}, "
f"env_variable={self._env_variable!r})" f"env_variable={self._env_variable!r}{path})"
) )
def __eq__(self, other: object) -> bool: def __eq__(self, other: object) -> bool:
@@ -103,10 +139,13 @@ class EnvVarSecret:
isinstance(other, EnvVarSecret) isinstance(other, EnvVarSecret)
and other._secret == self._secret and other._secret == self._secret
and other._env_variable == self._env_variable and other._env_variable == self._env_variable
and other._namespace_path == self._namespace_path
) )
def __hash__(self) -> int: def __hash__(self) -> int:
return hash((EnvVarSecret, self._secret, self._env_variable)) return hash(
(EnvVarSecret, self._secret, self._env_variable, self._namespace_path)
)
class SecretInfo: class SecretInfo:
@@ -15,6 +15,7 @@ from lancedb.functions import (
PythonRuntimeSpec, PythonRuntimeSpec,
SecretBinding, SecretBinding,
RefreshColumnResult, RefreshColumnResult,
SecretReference,
) )
from lancedb.table import AsyncTable from lancedb.table import AsyncTable
@@ -112,7 +113,9 @@ def test_function_version_identity_is_immutable_and_exact():
assert version.name == "embed" assert version.name == "embed"
assert version.version == "fv_01K3EXACT" assert version.version == "fv_01K3EXACT"
assert list(version.secret_bindings) == [ assert list(version.secret_bindings) == [
SecretBinding(kind="env", variable="HF_TOKEN", secret_ref="hf-prod") SecretBinding(
kind="env", variable="HF_TOKEN", secret_ref=SecretReference(name="hf-prod")
)
] ]
with pytest.raises((TypeError, ValueError)): with pytest.raises((TypeError, ValueError)):
@@ -303,7 +306,7 @@ def test_canonical_client_values_carry_bindings_and_no_credentials():
) )
canonical = json.loads(version.to_canonical_json()) canonical = json.loads(version.to_canonical_json())
assert canonical["secret_bindings"] == [ assert canonical["secret_bindings"] == [
{"kind": "env", "variable": "HF_TOKEN", "secret_ref": "hf-prod"} {"kind": "env", "variable": "HF_TOKEN", "secret_ref": {"name": "hf-prod"}}
] ]
assert_no_secret_values(canonical) assert_no_secret_values(canonical)
@@ -25,6 +25,7 @@ import lancedb
from lancedb.functions import ( from lancedb.functions import (
PythonRuntimeSpec, PythonRuntimeSpec,
SecretBinding, SecretBinding,
SecretReference,
UdfDefinition, UdfDefinition,
_canonical_arrow_type, _canonical_arrow_type,
_GRAMMAR_PRIMITIVES, _GRAMMAR_PRIMITIVES,
@@ -94,6 +95,67 @@ def test_secret_bound_udf_matches_its_shared_registration_golden():
) )
def test_a_namespaced_binding_records_the_path_and_the_name():
"""A binding names the parts, so nothing has to be parsed back out.
A root binding carries no path at all, which is what keeps its wire shape
identical to one written before namespaces existed.
"""
root = EnvVarSecret(secret="openai-prod", env_variable="OPENAI_API_KEY")
assert root.namespace_path == []
nested = EnvVarSecret(
secret="openai-prod",
env_variable="OPENAI_API_KEY",
namespace_path=["prod", "vision"],
)
assert nested.namespace_path == ["prod", "vision"]
assert nested != root
bound = analyze_caption.bind_secrets([nested])
assert list(bound.secret_bindings) == [
SecretBinding(
kind="env",
variable="OPENAI_API_KEY",
secret_ref=SecretReference(
name="openai-prod", namespace_path=("prod", "vision")
),
)
]
at_root = analyze_caption.bind_secrets([root])
assert list(at_root.secret_bindings) == [
SecretBinding(
kind="env",
variable="OPENAI_API_KEY",
secret_ref=SecretReference(name="openai-prod"),
)
]
# A root binding carries no path at all on the wire.
canonical = json.loads(at_root.to_canonical_json())
assert canonical["secret_bindings"] == [
{
"kind": "env",
"variable": "OPENAI_API_KEY",
"secret_ref": {"name": "openai-prod"},
}
]
def test_a_namespace_path_is_validated_locally():
# The charset is the service's, not a delimiter's: a reference is never
# joined, so a segment cannot make anything parse two ways.
with pytest.raises(ValueError):
EnvVarSecret(
secret="openai-prod", env_variable="K", namespace_path=["with$delim"]
)
with pytest.raises(ValueError):
EnvVarSecret(secret="openai-prod", env_variable="K", namespace_path=["a/b"])
# A bare string is a plausible mistake with the wrong meaning.
with pytest.raises(TypeError):
EnvVarSecret(secret="openai-prod", env_variable="K", namespace_path="prod")
def test_an_unbound_request_carries_no_binding_at_all(): def test_an_unbound_request_carries_no_binding_at_all():
"""Binding is a registration-time decision, so the definition holds none. """Binding is a registration-time decision, so the definition holds none.
@@ -168,9 +230,11 @@ def test_a_binding_envelope_reaches_the_service_for_it_to_judge():
sent = state["requests"][0][1] sent = state["requests"][0][1]
assert len(sent["secret_bindings"]) == 17 assert len(sent["secret_bindings"]) == 17
assert {"kind": "env", "variable": "TOKEN_0", "secret_ref": "secret-0"} in sent[ assert {
"secret_bindings" "kind": "env",
] "variable": "TOKEN_0",
"secret_ref": {"name": "secret-0"},
} in sent["secret_bindings"]
_SECRET_DEBUG_LOG_SOURCE = """ _SECRET_DEBUG_LOG_SOURCE = """
@@ -271,6 +335,24 @@ def test_a_secret_name_admits_what_a_namespace_name_does():
with pytest.raises(ValueError, match="invalid Secret name"): with pytest.raises(ValueError, match="invalid Secret name"):
EnvVarSecret(secret=name, env_variable="OPENAI_API_KEY") EnvVarSecret(secret=name, env_variable="OPENAI_API_KEY")
# A namespace segment follows the same rule, and LanceDB already admits
# these shapes as namespace names -- so a Secret is addressable inside one.
for segment in [".hidden", "_internal", "-lead", "trailing."]:
binding = EnvVarSecret(
secret="openai-prod",
env_variable="OPENAI_API_KEY",
namespace_path=[segment],
)
assert binding.namespace_path == [segment]
for segment in ["", "with/slash", "with$delimiter"]:
with pytest.raises(ValueError, match="invalid namespace path segment"):
EnvVarSecret(
secret="openai-prod",
env_variable="OPENAI_API_KEY",
namespace_path=[segment],
)
def _main_udf_source( def _main_udf_source(
*, threshold: int = 20, input_annotation: str = "int", comparison: str = ">=" *, threshold: int = 20, input_annotation: str = "int", comparison: str = ">="
@@ -1545,12 +1627,20 @@ def test_remote_registration_sends_bindings_and_never_a_credential():
) )
assert list(created.secret_bindings) == [ assert list(created.secret_bindings) == [
SecretBinding(kind="env", variable="OPENAI_API_KEY", secret_ref="openai-prod") SecretBinding(
kind="env",
variable="OPENAI_API_KEY",
secret_ref=SecretReference(name="openai-prod"),
)
] ]
path, create_request = state["requests"][0] path, create_request = state["requests"][0]
assert path == "/v1/functions/create" assert path == "/v1/functions/create"
assert create_request["secret_bindings"] == [ assert create_request["secret_bindings"] == [
{"kind": "env", "variable": "OPENAI_API_KEY", "secret_ref": "openai-prod"} {
"kind": "env",
"variable": "OPENAI_API_KEY",
"secret_ref": {"name": "openai-prod"},
}
] ]
# The request names a Secret and carries nothing that could be one. # The request names a Secret and carries nothing that could be one.
assert create_request == json.loads( assert create_request == json.loads(
+30 -8
View File
@@ -708,10 +708,14 @@ impl Connection {
self_: PyRef<'_, Self>, self_: PyRef<'_, Self>,
name: String, name: String,
value: String, value: String,
namespace_path: Vec<String>,
) -> PyResult<Bound<'_, PyAny>> { ) -> PyResult<Bound<'_, PyAny>> {
let inner = self_.get_inner()?.clone(); let inner = self_.get_inner()?.clone();
future_into_py(self_.py(), async move { future_into_py(self_.py(), async move {
inner.create_secret(name, value).await.infer_error() inner
.create_secret(name, value, &namespace_path)
.await
.infer_error()
}) })
} }
@@ -719,34 +723,52 @@ impl Connection {
self_: PyRef<'_, Self>, self_: PyRef<'_, Self>,
name: String, name: String,
value: String, value: String,
namespace_path: Vec<String>,
) -> PyResult<Bound<'_, PyAny>> { ) -> PyResult<Bound<'_, PyAny>> {
let inner = self_.get_inner()?.clone(); let inner = self_.get_inner()?.clone();
future_into_py(self_.py(), async move { future_into_py(self_.py(), async move {
inner.alter_secret(name, value).await.infer_error() inner
.alter_secret(name, value, &namespace_path)
.await
.infer_error()
}) })
} }
pub fn list_secrets(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> { pub fn list_secrets(
self_: PyRef<'_, Self>,
namespace_path: Vec<String>,
) -> PyResult<Bound<'_, PyAny>> {
let inner = self_.get_inner()?.clone(); let inner = self_.get_inner()?.clone();
future_into_py(self_.py(), async move { future_into_py(self_.py(), async move {
inner.list_secrets().await.infer_error() inner.list_secrets(&namespace_path).await.infer_error()
}) })
} }
pub fn drop_secret(self_: PyRef<'_, Self>, name: String) -> PyResult<Bound<'_, PyAny>> { pub fn drop_secret(
self_: PyRef<'_, Self>,
name: String,
namespace_path: Vec<String>,
) -> PyResult<Bound<'_, PyAny>> {
let inner = self_.get_inner()?.clone(); let inner = self_.get_inner()?.clone();
future_into_py(self_.py(), async move { future_into_py(self_.py(), async move {
inner.drop_secret(name).await.infer_error() inner.drop_secret(name, &namespace_path).await.infer_error()
}) })
} }
/// Name and timestamps as a plain tuple. `SecretInfo` carries no value, so /// Name and timestamps as a plain tuple. `SecretInfo` carries no value, so
/// there is none to filter out here. Timestamps stay integers rather than /// there is none to filter out here. Timestamps stay integers rather than
/// going through a string, so the caller can compare two without parsing. /// going through a string, so the caller can compare two without parsing.
pub fn describe_secret(self_: PyRef<'_, Self>, name: String) -> PyResult<Bound<'_, PyAny>> { pub fn describe_secret(
self_: PyRef<'_, Self>,
name: String,
namespace_path: Vec<String>,
) -> PyResult<Bound<'_, PyAny>> {
let inner = self_.get_inner()?.clone(); let inner = self_.get_inner()?.clone();
future_into_py(self_.py(), async move { future_into_py(self_.py(), async move {
let info = inner.describe_secret(name).await.infer_error()?; let info = inner
.describe_secret(name, &namespace_path)
.await
.infer_error()?;
Ok((info.name, info.created_at_millis, info.updated_at_millis)) Ok((info.name, info.created_at_millis, info.updated_at_millis))
}) })
} }
+32 -10
View File
@@ -653,9 +653,14 @@ impl Connection {
/// rotation. There is no API that reads a stored credential back; the only /// rotation. There is no API that reads a stored credential back; the only
/// consumer is a Function that binds the Secret by name. Local databases /// consumer is a Function that binds the Secret by name. Local databases
/// return [`Error::NotSupported`]. /// return [`Error::NotSupported`].
pub async fn create_secret(&self, name: impl AsRef<str>, value: impl AsRef<str>) -> Result<()> { pub async fn create_secret(
&self,
name: impl AsRef<str>,
value: impl AsRef<str>,
namespace_path: &[String],
) -> Result<()> {
self.internal self.internal
.create_secret(name.as_ref(), value.as_ref()) .create_secret(name.as_ref(), value.as_ref(), namespace_path)
.await .await
} }
@@ -666,9 +671,14 @@ impl Connection {
/// minted -- which is what lets a rotation reach columns pinned to a /// minted -- which is what lets a rotation reach columns pinned to a
/// version registered before it. Local databases return /// version registered before it. Local databases return
/// [`Error::NotSupported`]. /// [`Error::NotSupported`].
pub async fn alter_secret(&self, name: impl AsRef<str>, value: impl AsRef<str>) -> Result<()> { pub async fn alter_secret(
&self,
name: impl AsRef<str>,
value: impl AsRef<str>,
namespace_path: &[String],
) -> Result<()> {
self.internal self.internal
.alter_secret(name.as_ref(), value.as_ref()) .alter_secret(name.as_ref(), value.as_ref(), namespace_path)
.await .await
} }
@@ -677,8 +687,8 @@ impl Connection {
/// Names only. No path in this API returns a stored credential, by /// Names only. No path in this API returns a stored credential, by
/// construction rather than by policy. Local databases return /// construction rather than by policy. Local databases return
/// [`Error::NotSupported`]. /// [`Error::NotSupported`].
pub async fn list_secrets(&self) -> Result<Vec<String>> { pub async fn list_secrets(&self, namespace_path: &[String]) -> Result<Vec<String>> {
self.internal.list_secrets().await self.internal.list_secrets(namespace_path).await
} }
/// Drop a Secret. /// Drop a Secret.
@@ -687,8 +697,14 @@ impl Connection {
/// is the revocation path. The name becomes free to reuse, and a new /// is the revocation path. The name becomes free to reuse, and a new
/// Secret under it is picked up by everything still bound to that name. /// Secret under it is picked up by everything still bound to that name.
/// Local databases return [`Error::NotSupported`]. /// Local databases return [`Error::NotSupported`].
pub async fn drop_secret(&self, name: impl AsRef<str>) -> Result<()> { pub async fn drop_secret(
self.internal.drop_secret(name.as_ref()).await &self,
name: impl AsRef<str>,
namespace_path: &[String],
) -> Result<()> {
self.internal
.drop_secret(name.as_ref(), namespace_path)
.await
} }
/// What this database records about one Secret: its name and timestamps. /// What this database records about one Secret: its name and timestamps.
@@ -696,8 +712,14 @@ impl Connection {
/// Never the value. The type it returns has no field for one, so this is a /// Never the value. The type it returns has no field for one, so this is a
/// property of the API rather than of what the caller chooses to read. /// property of the API rather than of what the caller chooses to read.
/// Local databases return [`Error::NotSupported`]. /// Local databases return [`Error::NotSupported`].
pub async fn describe_secret(&self, name: impl AsRef<str>) -> Result<SecretInfo> { pub async fn describe_secret(
self.internal.describe_secret(name.as_ref()).await &self,
name: impl AsRef<str>,
namespace_path: &[String],
) -> Result<SecretInfo> {
self.internal
.describe_secret(name.as_ref(), namespace_path)
.await
} }
/// Rename a table in the database. /// Rename a table in the database.
+15 -5
View File
@@ -326,30 +326,40 @@ pub trait Database:
} }
/// Create a named Secret in this database. Fails if the name is taken, so /// Create a named Secret in this database. Fails if the name is taken, so
/// a create can never silently become a rotation. /// a create can never silently become a rotation.
async fn create_secret(&self, _name: &str, _value: &str) -> Result<()> { async fn create_secret(
&self,
_name: &str,
_value: &str,
_namespace_path: &[String],
) -> Result<()> {
secret_catalog_not_supported() secret_catalog_not_supported()
} }
/// Replace the credential behind an existing Secret. Fails if it does not /// Replace the credential behind an existing Secret. Fails if it does not
/// exist. Every Function bound to it resolves the new value from its next /// exist. Every Function bound to it resolves the new value from its next
/// execution, with no new Function version. /// execution, with no new Function version.
async fn alter_secret(&self, _name: &str, _value: &str) -> Result<()> { async fn alter_secret(
&self,
_name: &str,
_value: &str,
_namespace_path: &[String],
) -> Result<()> {
secret_catalog_not_supported() secret_catalog_not_supported()
} }
/// The names of every Secret in this database. /// The names of every Secret in this database.
/// ///
/// Names only. No API path returns a stored credential, by construction /// Names only. No API path returns a stored credential, by construction
/// rather than by policy. /// rather than by policy.
async fn list_secrets(&self) -> Result<Vec<String>> { async fn list_secrets(&self, _namespace_path: &[String]) -> Result<Vec<String>> {
secret_catalog_not_supported() secret_catalog_not_supported()
} }
/// Drop a Secret. Functions bound to it fail at their next job, which is /// Drop a Secret. Functions bound to it fail at their next job, which is
/// the revocation path. /// the revocation path.
async fn drop_secret(&self, _name: &str) -> Result<()> { async fn drop_secret(&self, _name: &str, _namespace_path: &[String]) -> Result<()> {
secret_catalog_not_supported() secret_catalog_not_supported()
} }
/// What the database records about one Secret: its name and timestamps, /// What the database records about one Secret: its name and timestamps,
/// never its value. /// never its value.
async fn describe_secret(&self, _name: &str) -> Result<SecretInfo> { async fn describe_secret(&self, _name: &str, _namespace_path: &[String]) -> Result<SecretInfo> {
secret_catalog_not_supported() secret_catalog_not_supported()
} }
/// Open a job by id, returning a handle with its record already /// Open a job by id, returning a handle with its record already
+38 -4
View File
@@ -493,6 +493,40 @@ pub struct FunctionArtifactRequest {
pub adapter: PythonAdapterSpec, pub adapter: PythonAdapterSpec,
} }
/// Where a Secret lives, carried as its parts rather than as one string.
///
/// A joined id would need a delimiter, and a delimiter has to be excluded from
/// every name and segment forever, agreed on by both sides, and re-agreed each
/// time either grows a new way to be configured. Naming the parts costs one
/// object and settles all of that: nothing here is parsed, so nothing can parse
/// two ways.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct SecretReference {
pub name: String,
/// The namespace holding the Secret. Empty is the root, and is omitted from
/// the wire so a root binding carries no trace of a feature it does not use.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub namespace_path: Vec<String>,
}
impl SecretReference {
/// A Secret in the root namespace.
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
namespace_path: Vec::new(),
}
}
/// A Secret in `namespace_path`.
pub fn in_namespace(name: impl Into<String>, namespace_path: Vec<String>) -> Self {
Self {
name: name.into(),
namespace_path,
}
}
}
/// How a Secret reaches the Function that binds it. /// How a Secret reaches the Function that binds it.
/// ///
/// One list rather than a field per delivery mode: a binding is the concept, /// One list rather than a field per delivery mode: a binding is the concept,
@@ -513,7 +547,7 @@ pub enum SecretBinding {
/// Named `secret_ref` rather than `secret` because a Job payload is /// Named `secret_ref` rather than `secret` because a Job payload is
/// scanned server-side for credential-shaped keys, and a key called /// scanned server-side for credential-shaped keys, and a key called
/// `secret` trips that guard whatever it actually holds. /// `secret` trips that guard whatever it actually holds.
secret_ref: String, secret_ref: SecretReference,
}, },
/// A binding kind introduced by a newer server. /// A binding kind introduced by a newer server.
Unrecognized { kind: String }, Unrecognized { kind: String },
@@ -538,7 +572,7 @@ impl SecretBinding {
} }
/// The Secret bound, or `None` for a kind this client cannot read. /// The Secret bound, or `None` for a kind this client cannot read.
pub fn secret(&self) -> Option<&str> { pub fn secret(&self) -> Option<&SecretReference> {
match self { match self {
Self::Env { secret_ref, .. } => Some(secret_ref), Self::Env { secret_ref, .. } => Some(secret_ref),
Self::Unrecognized { .. } => None, Self::Unrecognized { .. } => None,
@@ -549,7 +583,7 @@ impl SecretBinding {
#[derive(Deserialize)] #[derive(Deserialize)]
struct EnvSecretBindingWire { struct EnvSecretBindingWire {
variable: String, variable: String,
secret_ref: String, secret_ref: SecretReference,
} }
impl<'de> Deserialize<'de> for SecretBinding { impl<'de> Deserialize<'de> for SecretBinding {
@@ -581,7 +615,7 @@ impl Serialize for SecretBinding {
struct EnvBindingRef<'a> { struct EnvBindingRef<'a> {
kind: &'static str, kind: &'static str,
variable: &'a str, variable: &'a str,
secret_ref: &'a str, secret_ref: &'a SecretReference,
} }
#[derive(Serialize)] #[derive(Serialize)]
+90 -21
View File
@@ -278,6 +278,22 @@ pub struct RemoteHostOverrides {
pub sql: Option<String>, pub sql: Option<String>,
} }
/// Attach a namespace path to a Secret request body.
///
/// A root path is omitted rather than sent empty, so a root request is byte
/// identical to one from a client that predates namespace addressing.
fn add_namespace_path(body: &mut serde_json::Value, namespace_path: &[String]) {
if namespace_path.is_empty() {
return;
}
body["namespace_path"] = serde_json::Value::Array(
namespace_path
.iter()
.map(|segment| serde_json::Value::String(segment.clone()))
.collect(),
);
}
impl RemoteDatabase { impl RemoteDatabase {
pub(crate) fn try_new( pub(crate) fn try_new(
uri: &str, uri: &str,
@@ -599,6 +615,10 @@ struct RemoteDropFunctionResponse {
struct RemoteCreateSecretRequest<'a> { struct RemoteCreateSecretRequest<'a> {
name: &'a str, name: &'a str,
value: &'a str, value: &'a str,
/// Omitted at the root, so a request from a client that predates namespaces
/// is byte-identical to one that does not use them.
#[serde(skip_serializing_if = "<[String]>::is_empty")]
namespace_path: &'a [String],
} }
/// Replace the credential behind a Secret the database already holds. /// Replace the credential behind a Secret the database already holds.
@@ -606,15 +626,21 @@ struct RemoteCreateSecretRequest<'a> {
struct RemoteAlterSecretRequest<'a> { struct RemoteAlterSecretRequest<'a> {
name: &'a str, name: &'a str,
value: &'a str, value: &'a str,
#[serde(skip_serializing_if = "<[String]>::is_empty")]
namespace_path: &'a [String],
} }
/// One page of a Secret listing. A struct rather than an inline object so the /// One page of a Secret listing. A struct rather than an inline object so the
/// request and the response are declared the same way -- a reader of one finds /// request and the response are declared the same way -- a reader of one finds
/// the other. /// the other.
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
struct RemoteListSecretsRequest { struct RemoteListSecretsRequest<'a> {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
page_token: Option<String>, page_token: Option<String>,
/// Omitted at the root, so a listing from a client that predates namespaces
/// is byte-identical to one that does not use them.
#[serde(skip_serializing_if = "<[String]>::is_empty")]
namespace_path: &'a [String],
} }
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
@@ -733,29 +759,43 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
Ok(response.dropped) Ok(response.dropped)
} }
async fn create_secret(&self, name: &str, value: &str) -> Result<()> { async fn create_secret(
&self,
name: &str,
value: &str,
namespace_path: &[String],
) -> Result<()> {
self.post_secret_write( self.post_secret_write(
"/v1/secrets/create", "/v1/secrets/create",
&RemoteCreateSecretRequest { name, value }, &RemoteCreateSecretRequest {
name,
value,
namespace_path,
},
) )
.await .await
} }
async fn alter_secret(&self, name: &str, value: &str) -> Result<()> { async fn alter_secret(&self, name: &str, value: &str, namespace_path: &[String]) -> Result<()> {
self.post_secret_write( self.post_secret_write(
"/v1/secrets/alter", "/v1/secrets/alter",
&RemoteAlterSecretRequest { name, value }, &RemoteAlterSecretRequest {
name,
value,
namespace_path,
},
) )
.await .await
} }
async fn list_secrets(&self) -> Result<Vec<String>> { async fn list_secrets(&self, namespace_path: &[String]) -> Result<Vec<String>> {
let mut names = Vec::new(); let mut names = Vec::new();
let mut page_token: Option<String> = None; let mut page_token: Option<String> = None;
let mut seen_page_tokens = HashSet::new(); let mut seen_page_tokens = HashSet::new();
loop { loop {
let body = RemoteListSecretsRequest { let body = RemoteListSecretsRequest {
page_token: page_token.clone(), page_token: page_token.clone(),
namespace_path,
}; };
let req = self.client.post("/v1/secrets/list").json(&body); let req = self.client.post("/v1/secrets/list").json(&body);
let (request_id, response) = self.client.send(req).await?; let (request_id, response) = self.client.send(req).await?;
@@ -780,21 +820,19 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
Ok(names) Ok(names)
} }
async fn drop_secret(&self, name: &str) -> Result<()> { async fn drop_secret(&self, name: &str, namespace_path: &[String]) -> Result<()> {
let req = self let mut body = serde_json::json!({ "name": name });
.client add_namespace_path(&mut body, namespace_path);
.post("/v1/secrets/drop") let req = self.client.post("/v1/secrets/drop").json(&body);
.json(&serde_json::json!({ "name": name }));
let (request_id, response) = self.client.send(req).await?; let (request_id, response) = self.client.send(req).await?;
self.client.check_response(&request_id, response).await?; self.client.check_response(&request_id, response).await?;
Ok(()) Ok(())
} }
async fn describe_secret(&self, name: &str) -> Result<SecretInfo> { async fn describe_secret(&self, name: &str, namespace_path: &[String]) -> Result<SecretInfo> {
let req = self let mut body = serde_json::json!({ "name": name });
.client add_namespace_path(&mut body, namespace_path);
.post("/v1/secrets/describe") let req = self.client.post("/v1/secrets/describe").json(&body);
.json(&serde_json::json!({ "name": name }));
let (request_id, response) = self.client.send(req).await?; let (request_id, response) = self.client.send(req).await?;
let response = self.client.check_response(&request_id, response).await?; let response = self.client.check_response(&request_id, response).await?;
response.json().await.err_to_http(request_id) response.json().await.err_to_http(request_id)
@@ -2926,11 +2964,11 @@ mod tests {
http::Response::builder().status(200).body("{}").unwrap() http::Response::builder().status(200).body("{}").unwrap()
}); });
if call { if call {
conn.create_secret("openai-prod", "sk-live-0001") conn.create_secret("openai-prod", "sk-live-0001", &[])
.await .await
.unwrap(); .unwrap();
} else { } else {
conn.alter_secret("openai-prod", "sk-live-0001") conn.alter_secret("openai-prod", "sk-live-0001", &[])
.await .await
.unwrap(); .unwrap();
} }
@@ -2952,7 +2990,7 @@ mod tests {
http::Response::builder().status(200).body(body).unwrap() http::Response::builder().status(200).body(body).unwrap()
}); });
assert_eq!( assert_eq!(
conn.list_secrets().await.unwrap(), conn.list_secrets(&[]).await.unwrap(),
vec!["openai-prod".to_string(), "hf-prod".to_string()] vec!["openai-prod".to_string(), "hf-prod".to_string()]
); );
} }
@@ -2967,7 +3005,7 @@ mod tests {
.body(r#"{"secrets":[{"name":"openai-prod"}],"page_token":"same"}"#) .body(r#"{"secrets":[{"name":"openai-prod"}],"page_token":"same"}"#)
.unwrap() .unwrap()
}); });
let error = conn.list_secrets().await.unwrap_err(); let error = conn.list_secrets(&[]).await.unwrap_err();
assert!( assert!(
error.to_string().contains("repeated a page_token"), error.to_string().contains("repeated a page_token"),
"{error}" "{error}"
@@ -2983,7 +3021,38 @@ mod tests {
assert_eq!(body, serde_json::json!({"name": "openai-prod"})); assert_eq!(body, serde_json::json!({"name": "openai-prod"}));
http::Response::builder().status(200).body("{}").unwrap() http::Response::builder().status(200).body("{}").unwrap()
}); });
conn.drop_secret("openai-prod").await.unwrap(); conn.drop_secret("openai-prod", &[]).await.unwrap();
}
/// A namespace path is sent when there is one and omitted when there is
/// not, so a root request stays byte identical to one from a client that
/// predates namespace addressing -- which is what lets the parameter ship
/// before every server implements it.
#[tokio::test]
async fn test_a_namespace_path_is_sent_only_when_it_is_not_root() {
let conn = Connection::new_with_handler(|request| {
let body: serde_json::Value =
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
assert_eq!(
body,
serde_json::json!({
"name": "openai-prod",
"namespace_path": ["prod", "vision"],
})
);
http::Response::builder().status(200).body("{}").unwrap()
});
conn.drop_secret("openai-prod", &["prod".to_string(), "vision".to_string()])
.await
.unwrap();
let conn = Connection::new_with_handler(|request| {
let body: serde_json::Value =
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
assert!(body.get("namespace_path").is_none(), "{body}");
http::Response::builder().status(200).body("{}").unwrap()
});
conn.drop_secret("openai-prod", &[]).await.unwrap();
} }
#[tokio::test] #[tokio::test]
@@ -6,6 +6,7 @@ use std::path::PathBuf;
use lancedb::function::{ use lancedb::function::{
FunctionApplication, FunctionBinding, FunctionVersion, RefreshColumnResult, SecretBinding, FunctionApplication, FunctionBinding, FunctionVersion, RefreshColumnResult, SecretBinding,
SecretReference,
}; };
use serde_json::Value; use serde_json::Value;
@@ -52,7 +53,7 @@ fn function_version_job_result_matches_shared_canonical_golden() {
version.secret_bindings(), version.secret_bindings(),
[SecretBinding::Env { [SecretBinding::Env {
variable: "HF_TOKEN".to_string(), variable: "HF_TOKEN".to_string(),
secret_ref: "hf-prod".to_string(), secret_ref: SecretReference::new("hf-prod"),
}] }]
); );
assert_eq!( assert_eq!(
@@ -183,7 +184,7 @@ fn canonical_client_values_carry_bindings_and_no_credentials() {
assert_eq!( assert_eq!(
canonical["secret_bindings"], canonical["secret_bindings"],
serde_json::json!([{"kind": "env", "variable": "HF_TOKEN", "secret_ref": "hf-prod"}]) serde_json::json!([{"kind": "env", "variable": "HF_TOKEN", "secret_ref": {"name": "hf-prod"}}])
); );
assert_no_secret_values(&canonical); assert_no_secret_values(&canonical);
} }
@@ -198,8 +199,8 @@ fn canonical_client_values_carry_bindings_and_no_credentials() {
fn an_unknown_binding_kind_is_forward_decodable() { fn an_unknown_binding_kind_is_forward_decodable() {
let mut result = job_result("remote_function_job.json"); let mut result = job_result("remote_function_job.json");
result["secret_bindings"] = serde_json::json!([ result["secret_bindings"] = serde_json::json!([
{"kind": "env", "variable": "HF_TOKEN", "secret_ref": "hf-prod"}, {"kind": "env", "variable": "HF_TOKEN", "secret_ref": {"name": "hf-prod"}},
{"kind": "file", "path": "/run/secrets/tok", "secret_ref": "hf-prod"}, {"kind": "file", "path": "/run/secrets/tok", "secret_ref": {"name": "hf-prod"}},
]); ]);
let version = FunctionVersion::from_json(&result.to_string()).expect("future binding kind"); let version = FunctionVersion::from_json(&result.to_string()).expect("future binding kind");
@@ -5,7 +5,7 @@ use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use lancedb::Error; use lancedb::Error;
use lancedb::function::{FunctionRegistrationRequest, SecretBinding}; use lancedb::function::{FunctionRegistrationRequest, SecretBinding, SecretReference};
use serde_json::Value; use serde_json::Value;
fn fixture(name: &str) -> String { fn fixture(name: &str) -> String {
@@ -70,7 +70,7 @@ fn secret_bound_registration_request_matches_shared_canonical_golden() {
request.secret_bindings, request.secret_bindings,
[SecretBinding::Env { [SecretBinding::Env {
variable: "OPENAI_API_KEY".to_string(), variable: "OPENAI_API_KEY".to_string(),
secret_ref: "openai-prod".to_string(), secret_ref: SecretReference::new("openai-prod"),
}] }]
); );
assert_eq!( assert_eq!(
@@ -48,7 +48,9 @@
{ {
"kind": "env", "kind": "env",
"variable": "HF_TOKEN", "variable": "HF_TOKEN",
"secret_ref": "hf-prod" "secret_ref": {
"name": "hf-prod"
}
} }
] ]
}, },
@@ -1 +1 @@
{"artifact":{"adapter":{"kind":"scalar_to_arrow_batch","version":1},"content":{"data":"ZnJvbSBfX2Z1dHVyZV9fIGltcG9ydCBhbm5vdGF0aW9ucwoKZGVmIGFuYWx5emVfY2FwdGlvbihjYXB0aW9uOiBzdHIpIC0+IHN0cjoKICAgIHJldHVybiBjYXB0aW9uLnN0cmlwKCkK","encoding":"base64"},"digest":"sha256:800462c9ad15151a80f83f85b8912ff149300c1563e07f58448f099afcd0d077","entrypoint":"analyze_caption","kind":"python_callable"},"name":"analyze_caption","runtime":{"env":{"MODE":"test"},"environment":{"kind":"pip","packages":["openai==3.7.0"]},"kind":"python","python_version":"3.12"},"secret_bindings":[{"kind":"env","secret_ref":"openai-prod","variable":"OPENAI_API_KEY"}],"signature":{"inputs":[{"arrow_type":"utf8","name":"caption","nullable":false}],"output":{"arrow_type":"utf8","kind":"scalar","nullable":false}}} {"artifact":{"adapter":{"kind":"scalar_to_arrow_batch","version":1},"content":{"data":"ZnJvbSBfX2Z1dHVyZV9fIGltcG9ydCBhbm5vdGF0aW9ucwoKZGVmIGFuYWx5emVfY2FwdGlvbihjYXB0aW9uOiBzdHIpIC0+IHN0cjoKICAgIHJldHVybiBjYXB0aW9uLnN0cmlwKCkK","encoding":"base64"},"digest":"sha256:800462c9ad15151a80f83f85b8912ff149300c1563e07f58448f099afcd0d077","entrypoint":"analyze_caption","kind":"python_callable"},"name":"analyze_caption","runtime":{"env":{"MODE":"test"},"environment":{"kind":"pip","packages":["openai==3.7.0"]},"kind":"python","python_version":"3.12"},"secret_bindings":[{"kind":"env","secret_ref":{"name":"openai-prod"},"variable":"OPENAI_API_KEY"}],"signature":{"inputs":[{"arrow_type":"utf8","name":"caption","nullable":false}],"output":{"arrow_type":"utf8","kind":"scalar","nullable":false}}}
@@ -44,7 +44,9 @@
{ {
"kind": "env", "kind": "env",
"variable": "OPENAI_API_KEY", "variable": "OPENAI_API_KEY",
"secret_ref": "openai-prod" "secret_ref": {
"name": "openai-prod"
}
} }
] ]
} }
@@ -1 +1 @@
{"artifact":{"digest":"sha256:code","entrypoint":"embed","kind":"python_callable"},"created_at":"2026-08-21T00:00:00Z","environment_digest":"sha256:environment","name":"embed","runtime":{"env":{"TOKENIZERS_PARALLELISM":"false"},"environment":{"kind":"pip","packages":["sentence-transformers>=3"]},"kind":"python","python_version":"3.12"},"runtime_digest":"sha256:runtime","secret_bindings":[{"kind":"env","secret_ref":"hf-prod","variable":"HF_TOKEN"}],"signature":{"inputs":[{"arrow_type":"utf8","name":"text","nullable":true}],"output":{"arrow_type":"list<float32>","kind":"scalar","nullable":false}},"version":"fv_01K3EXACT"} {"artifact":{"digest":"sha256:code","entrypoint":"embed","kind":"python_callable"},"created_at":"2026-08-21T00:00:00Z","environment_digest":"sha256:environment","name":"embed","runtime":{"env":{"TOKENIZERS_PARALLELISM":"false"},"environment":{"kind":"pip","packages":["sentence-transformers>=3"]},"kind":"python","python_version":"3.12"},"runtime_digest":"sha256:runtime","secret_bindings":[{"kind":"env","secret_ref":{"name":"hf-prod"},"variable":"HF_TOKEN"}],"signature":{"inputs":[{"arrow_type":"utf8","name":"text","nullable":true}],"output":{"arrow_type":"list<float32>","kind":"scalar","nullable":false}},"version":"fv_01K3EXACT"}