diff --git a/python/python/lancedb/functions.py b/python/python/lancedb/functions.py index 46d6c9c1a..bd669610d 100644 --- a/python/python/lancedb/functions.py +++ b/python/python/lancedb/functions.py @@ -227,6 +227,20 @@ class FunctionOutput(_OpenRemoteValue): fields: tuple[FunctionResultField, ...] = () +class SecretBinding(_RemoteValue): + """How a Secret reaches the Function that binds it. + + One list rather than a field per delivery mode: a binding is the concept, + and how it arrives is a property of one. ``kind`` is open, so a binding a + newer service introduces decodes here instead of failing the whole + FunctionVersion. + """ + + kind: str + variable: Optional[str] = None + secret_ref: Optional[str] = None + + class FunctionSignature(_RemoteValue): inputs: tuple[FunctionParameter, ...] output: FunctionOutput @@ -310,7 +324,7 @@ class FunctionVersion(_RemoteValue): runtime: PythonRuntimeSpec runtime_digest: str environment_digest: str - secret_env_bindings: Mapping[str, str] = {} + secret_bindings: tuple[SecretBinding, ...] = () created_at: str def __call__(self, **inputs: Any) -> FunctionApplication: @@ -375,7 +389,7 @@ class FunctionRegistrationRequest(_RemoteValue): """Stable remote registration envelope produced by :func:`udf`. Credential values deliberately have no field here. The only secret-shaped - thing a client sends is ``secret_env_bindings``: the name of a Secret the + thing a client sends is ``secret_bindings``: the name of a Secret the database already holds, which the remote service resolves at execution. """ @@ -383,7 +397,7 @@ class FunctionRegistrationRequest(_RemoteValue): artifact: FunctionArtifactRequest signature: FunctionSignature runtime: PythonRuntimeSpec - secret_env_bindings: Mapping[str, str] = {} + secret_bindings: tuple[SecretBinding, ...] = () class FunctionVersionRef(_OpenRemoteValue): @@ -1322,8 +1336,22 @@ class UdfDefinition: ) if not bindings: return self._request - resolved = {binding.env_variable: binding.secret for binding in bindings} - return self._request._copy(update={"secret_env_bindings": resolved}) + # Sorted, because the list is carried in the FunctionVersion hash and a + # caller's argument order is not part of what a Function is. + resolved = tuple( + sorted( + ( + SecretBinding( + kind="env", + variable=binding.env_variable, + secret_ref=binding.secret, + ) + for binding in bindings + ), + key=lambda binding: (binding.kind, binding.variable or ""), + ) + ) + return self._request._copy(update={"secret_bindings": resolved}) def __call__(self, *args, **kwargs): return self._function(*args, **kwargs) diff --git a/python/python/tests/test_first_class_function_slice1.py b/python/python/tests/test_first_class_function_slice1.py index b39884b70..4e6548c19 100644 --- a/python/python/tests/test_first_class_function_slice1.py +++ b/python/python/tests/test_first_class_function_slice1.py @@ -13,6 +13,7 @@ from lancedb.functions import ( FunctionBinding, FunctionVersion, PythonRuntimeSpec, + SecretBinding, RefreshColumnResult, ) from lancedb.table import AsyncTable @@ -110,7 +111,9 @@ def test_function_version_identity_is_immutable_and_exact(): version = FunctionVersion.from_json(json.dumps(value)) assert version.name == "embed" assert version.version == "fv_01K3EXACT" - assert dict(version.secret_env_bindings) == {"HF_TOKEN": "hf-prod"} + assert list(version.secret_bindings) == [ + SecretBinding(kind="env", variable="HF_TOKEN", secret_ref="hf-prod") + ] with pytest.raises((TypeError, ValueError)): version.version = "fv_changed" @@ -299,17 +302,19 @@ def test_canonical_client_values_carry_bindings_and_no_credentials(): json.dumps(job_result("remote_function_job.json")) ) canonical = json.loads(version.to_canonical_json()) - assert canonical["secret_env_bindings"] == {"HF_TOKEN": "hf-prod"} + assert canonical["secret_bindings"] == [ + {"kind": "env", "variable": "HF_TOKEN", "secret_ref": "hf-prod"} + ] assert_no_secret_values(canonical) def test_a_version_without_bindings_keeps_the_original_wire_shape(): """Every Function registered before Secrets existed serializes unchanged.""" value = job_result("remote_function_job.json") - del value["secret_env_bindings"] + del value["secret_bindings"] version = FunctionVersion.from_json(json.dumps(value)) - assert dict(version.secret_env_bindings) == {} - assert "secret_env_bindings" not in json.loads(version.to_canonical_json()) + assert list(version.secret_bindings) == [] + assert "secret_bindings" not in json.loads(version.to_canonical_json()) class _FunctionDeclarationInner: diff --git a/python/python/tests/test_first_class_function_slice2.py b/python/python/tests/test_first_class_function_slice2.py index 2932c42d0..be9169859 100644 --- a/python/python/tests/test_first_class_function_slice2.py +++ b/python/python/tests/test_first_class_function_slice2.py @@ -24,6 +24,7 @@ import pytest import lancedb from lancedb.functions import ( PythonRuntimeSpec, + SecretBinding, UdfDefinition, _canonical_arrow_type, _GRAMMAR_PRIMITIVES, @@ -101,7 +102,7 @@ def test_an_unbound_request_carries_no_binding_at_all(): whether or not a credential is later bound to it. """ unbound = json.loads(analyze_caption.registration_request.to_canonical_json()) - assert "secret_env_bindings" not in unbound + assert "secret_bindings" not in unbound assert "OPENAI_API_KEY" not in json.dumps(unbound) @@ -121,7 +122,7 @@ def test_a_function_declaring_no_secret_is_registered_exactly_as_before(): == normalize_score.registration_request.to_canonical_json() ) assert ( - "secret_env_bindings" + "secret_bindings" not in normalize_score.registration_request.to_canonical_json() ) @@ -166,8 +167,10 @@ def test_a_binding_envelope_reaches_the_service_for_it_to_judge(): db.create_function(normalize_score, secrets=bindings) sent = state["requests"][0][1] - assert len(sent["secret_env_bindings"]) == 17 - assert sent["secret_env_bindings"]["TOKEN_0"] == "secret-0" + assert len(sent["secret_bindings"]) == 17 + assert {"kind": "env", "variable": "TOKEN_0", "secret_ref": "secret-0"} in sent[ + "secret_bindings" + ] _SECRET_DEBUG_LOG_SOURCE = """ @@ -1430,7 +1433,7 @@ def _mock_remote_function_catalog(): "runtime": body["runtime"], "runtime_digest": "sha256:runtime", "environment_digest": "sha256:environment", - "secret_env_bindings": body.get("secret_env_bindings", {}), + "secret_bindings": body.get("secret_bindings", []), "created_at": "2026-08-21T00:00:00Z", } response = {"job_id": "job-register"} @@ -1541,10 +1544,14 @@ def test_remote_registration_sends_bindings_and_never_a_credential(): secrets=[EnvVarSecret(secret="openai-prod", env_variable="OPENAI_API_KEY")], ) - assert dict(created.secret_env_bindings) == {"OPENAI_API_KEY": "openai-prod"} + assert list(created.secret_bindings) == [ + SecretBinding(kind="env", variable="OPENAI_API_KEY", secret_ref="openai-prod") + ] path, create_request = state["requests"][0] assert path == "/v1/functions/create" - assert create_request["secret_env_bindings"] == {"OPENAI_API_KEY": "openai-prod"} + assert create_request["secret_bindings"] == [ + {"kind": "env", "variable": "OPENAI_API_KEY", "secret_ref": "openai-prod"} + ] # The request names a Secret and carries nothing that could be one. assert create_request == json.loads( analyze_caption.bind_secrets( diff --git a/rust/lancedb/src/function.rs b/rust/lancedb/src/function.rs index 6ea34610e..ca09b1b26 100644 --- a/rust/lancedb/src/function.rs +++ b/rust/lancedb/src/function.rs @@ -409,8 +409,8 @@ pub struct FunctionVersion { runtime: PythonRuntimeSpec, runtime_digest: String, environment_digest: String, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - secret_env_bindings: BTreeMap, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + secret_bindings: Vec, created_at: String, } @@ -449,8 +449,8 @@ impl FunctionVersion { /// them are not, and resolve at execution. Rotating a bound Secret /// therefore changes what the same version runs with, and no value has a /// field in this model. - pub fn secret_env_bindings(&self) -> &BTreeMap { - &self.secret_env_bindings + pub fn secret_bindings(&self) -> &[SecretBinding] { + &self.secret_bindings } pub fn created_at(&self) -> &str { @@ -493,10 +493,121 @@ pub struct FunctionArtifactRequest { pub adapter: PythonAdapterSpec, } +/// How a Secret reaches the Function that binds it. +/// +/// One list rather than a field per delivery mode: a binding is the concept, +/// and how it arrives is a property of one. A mode added later is a variant +/// here, and the rules that are per-Function -- how many Secrets a Function may +/// bind, which ones it needs -- stay answerable from one place. +/// +/// Unknown kinds decode rather than failing the whole FunctionVersion, as +/// [`PythonRuntimeSpec`] does for runtimes. The payload is intentionally not +/// retained: the client does not proxy catalog values. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +#[non_exhaustive] +pub enum SecretBinding { + /// Delivered as an environment variable, which the UDF's library already + /// reads. The variable is the delivery target; the Secret is what fills it. + Env { + variable: String, + /// Named `secret_ref` rather than `secret` because a Job payload is + /// scanned server-side for credential-shaped keys, and a key called + /// `secret` trips that guard whatever it actually holds. + secret_ref: String, + }, + /// A binding kind introduced by a newer server. + Unrecognized { kind: String }, +} + +impl SecretBinding { + /// The wire discriminator reported by Sophon. + pub fn kind(&self) -> &str { + match self { + Self::Env { .. } => "env", + Self::Unrecognized { kind } => kind, + } + } + + /// The environment variable this binding fills, or `None` for a kind that + /// does not deliver through one. + pub fn variable(&self) -> Option<&str> { + match self { + Self::Env { variable, .. } => Some(variable), + Self::Unrecognized { .. } => None, + } + } + + /// The Secret bound, or `None` for a kind this client cannot read. + pub fn secret(&self) -> Option<&str> { + match self { + Self::Env { secret_ref, .. } => Some(secret_ref), + Self::Unrecognized { .. } => None, + } + } +} + +#[derive(Deserialize)] +struct EnvSecretBindingWire { + variable: String, + secret_ref: String, +} + +impl<'de> Deserialize<'de> for SecretBinding { + fn deserialize>(deserializer: D) -> std::result::Result { + let value = Value::deserialize(deserializer)?; + let kind = value + .get("kind") + .ok_or_else(|| de::Error::missing_field("kind"))? + .as_str() + .ok_or_else(|| de::Error::custom("secret binding kind must be a string"))? + .to_string(); + match kind.as_str() { + "env" => { + let wire: EnvSecretBindingWire = + serde_json::from_value(value).map_err(de::Error::custom)?; + Ok(Self::Env { + variable: wire.variable, + secret_ref: wire.secret_ref, + }) + } + _ => Ok(Self::Unrecognized { kind }), + } + } +} + +impl Serialize for SecretBinding { + fn serialize(&self, serializer: S) -> std::result::Result { + #[derive(Serialize)] + struct EnvBindingRef<'a> { + kind: &'static str, + variable: &'a str, + secret_ref: &'a str, + } + + #[derive(Serialize)] + struct UnrecognizedBindingRef<'a> { + kind: &'a str, + } + + match self { + Self::Env { + variable, + secret_ref, + } => EnvBindingRef { + kind: "env", + variable, + secret_ref, + } + .serialize(serializer), + Self::Unrecognized { kind } => UnrecognizedBindingRef { kind }.serialize(serializer), + } + } +} + /// Stable request envelope for remote immutable Function registration. /// /// Credential values deliberately have no field here. The only secret-shaped -/// thing a client sends is `secret_env_bindings`: the name of a Secret the +/// thing a client sends is `secret_bindings`: the name of a Secret the /// database already holds, which Sophon resolves inside the remote runtime. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FunctionRegistrationRequest { @@ -507,8 +618,8 @@ pub struct FunctionRegistrationRequest { /// Declared environment variable name to the Secret it binds. A binding is /// a reference: whether the Secret exists is answered when a column is /// declared against this version, not here. - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub secret_env_bindings: BTreeMap, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub secret_bindings: Vec, } impl_json!(FunctionRegistrationRequest); diff --git a/rust/lancedb/src/remote/db.rs b/rust/lancedb/src/remote/db.rs index c89ae11b7..1c60bef7f 100644 --- a/rust/lancedb/src/remote/db.rs +++ b/rust/lancedb/src/remote/db.rs @@ -353,15 +353,16 @@ impl RemoteDatabase { } impl RemoteDatabase { - /// `create` and `alter` differ only in which name state the server - /// requires, so they share one request shape. The value is a request field - /// and never a path segment or query parameter, which keeps it out of - /// access logs and proxy traces. - async fn write_secret(&self, route: &str, name: &str, value: &str) -> Result<()> { - let req = self.client.post(route).json(&serde_json::json!({ - "name": name, - "value": value, - })); + /// Post a request whose body carries a credential. + /// + /// Shared by the create and alter verbs, which declare their own request + /// types: the two mean different things to the service and are free to + /// diverge, so what they share is the posting and not the shape. + /// + /// The value is a request field and never a path segment or query + /// parameter, which keeps it out of access logs and proxy traces. + async fn post_secret_write(&self, route: &str, body: &T) -> Result<()> { + let req = self.client.post(route).json(body); // This call is what says the body is a credential. Nothing downstream // can tell from the bytes, and a route list in the transport would have // to be kept in step with endpoints declared here. @@ -588,6 +589,25 @@ struct RemoteDropFunctionResponse { dropped: bool, } +/// Create a Secret under a name the database does not yet hold. +/// +/// Declared separately from the alter request although the two are identical +/// today: they are different operations to the service -- one refuses an +/// existing name, the other requires it -- and either may grow a field the +/// other has no meaning for. +#[derive(serde::Serialize)] +struct RemoteCreateSecretRequest<'a> { + name: &'a str, + value: &'a str, +} + +/// Replace the credential behind a Secret the database already holds. +#[derive(serde::Serialize)] +struct RemoteAlterSecretRequest<'a> { + name: &'a str, + value: &'a str, +} + /// 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 /// the other. @@ -714,11 +734,19 @@ impl Database for RemoteDatabase { } async fn create_secret(&self, name: &str, value: &str) -> Result<()> { - self.write_secret("/v1/secrets/create", name, value).await + self.post_secret_write( + "/v1/secrets/create", + &RemoteCreateSecretRequest { name, value }, + ) + .await } async fn alter_secret(&self, name: &str, value: &str) -> Result<()> { - self.write_secret("/v1/secrets/alter", name, value).await + self.post_secret_write( + "/v1/secrets/alter", + &RemoteAlterSecretRequest { name, value }, + ) + .await } async fn list_secrets(&self) -> Result> { diff --git a/rust/lancedb/tests/first_class_function_slice1.rs b/rust/lancedb/tests/first_class_function_slice1.rs index a3bdf73b8..182510d1c 100644 --- a/rust/lancedb/tests/first_class_function_slice1.rs +++ b/rust/lancedb/tests/first_class_function_slice1.rs @@ -1,12 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The LanceDB Authors -use std::collections::BTreeMap; use std::fs; use std::path::PathBuf; use lancedb::function::{ - FunctionApplication, FunctionBinding, FunctionVersion, RefreshColumnResult, + FunctionApplication, FunctionBinding, FunctionVersion, RefreshColumnResult, SecretBinding, }; use serde_json::Value; @@ -50,8 +49,11 @@ fn function_version_job_result_matches_shared_canonical_golden() { assert_eq!(version.version(), "fv_01K3EXACT"); assert_eq!(version.runtime_digest(), "sha256:runtime"); assert_eq!( - version.secret_env_bindings(), - &BTreeMap::from([("HF_TOKEN".to_string(), "hf-prod".to_string())]) + version.secret_bindings(), + [SecretBinding::Env { + variable: "HF_TOKEN".to_string(), + secret_ref: "hf-prod".to_string(), + }] ); assert_eq!( version.to_canonical_json().expect("canonical JSON"), @@ -180,12 +182,46 @@ fn canonical_client_values_carry_bindings_and_no_credentials() { .expect("canonical JSON"); assert_eq!( - canonical["secret_env_bindings"], - serde_json::json!({"HF_TOKEN": "hf-prod"}) + canonical["secret_bindings"], + serde_json::json!([{"kind": "env", "variable": "HF_TOKEN", "secret_ref": "hf-prod"}]) ); assert_no_secret_values(&canonical); } +/// A binding kind a newer server introduces must not fail the whole version. +/// +/// This is the cost the union pays for being one field: an unknown variant is +/// a decode error unless it is caught, so it is caught -- and the payload is +/// dropped rather than retained, as `PythonRuntimeSpec` does, because the +/// client does not proxy catalog values. +#[test] +fn an_unknown_binding_kind_is_forward_decodable() { + let mut result = job_result("remote_function_job.json"); + result["secret_bindings"] = serde_json::json!([ + {"kind": "env", "variable": "HF_TOKEN", "secret_ref": "hf-prod"}, + {"kind": "file", "path": "/run/secrets/tok", "secret_ref": "hf-prod"}, + ]); + + let version = FunctionVersion::from_json(&result.to_string()).expect("future binding kind"); + + let kinds = version + .secret_bindings() + .iter() + .map(|binding| binding.kind()) + .collect::>(); + assert_eq!(kinds, ["env", "file"]); + assert_eq!(version.secret_bindings()[1].variable(), None); + assert_eq!(version.secret_bindings()[1].secret(), None); + + // The unknown kind round-trips as its discriminator and nothing more. + let canonical: Value = + serde_json::from_str(&version.to_canonical_json().expect("canonical")).expect("JSON"); + assert_eq!( + canonical["secret_bindings"][1], + serde_json::json!({"kind": "file"}) + ); +} + /// Every Function registered before Secrets existed serializes unchanged. #[test] fn a_version_without_bindings_keeps_the_original_wire_shape() { @@ -193,14 +229,14 @@ fn a_version_without_bindings_keeps_the_original_wire_shape() { result .as_object_mut() .expect("Function version object") - .remove("secret_env_bindings"); + .remove("secret_bindings"); let version = FunctionVersion::from_json(&result.to_string()).expect("FunctionVersion result"); - assert!(version.secret_env_bindings().is_empty()); + assert!(version.secret_bindings().is_empty()); assert!( !version .to_canonical_json() .expect("canonical FunctionVersion") - .contains("secret_env_bindings") + .contains("secret_bindings") ); } diff --git a/rust/lancedb/tests/first_class_function_slice2.rs b/rust/lancedb/tests/first_class_function_slice2.rs index 89d6a9a98..da1dcedb0 100644 --- a/rust/lancedb/tests/first_class_function_slice2.rs +++ b/rust/lancedb/tests/first_class_function_slice2.rs @@ -5,7 +5,7 @@ use std::fs; use std::path::PathBuf; use lancedb::Error; -use lancedb::function::FunctionRegistrationRequest; +use lancedb::function::{FunctionRegistrationRequest, SecretBinding}; use serde_json::Value; fn fixture(name: &str) -> String { @@ -44,8 +44,8 @@ fn registration_request_matches_shared_canonical_golden() { assert_eq!(request.name, "normalize_score"); assert_eq!(request.artifact.adapter.kind, "scalar_to_arrow_batch"); // The unchanged path: a Function that binds nothing serializes today's - // bytes, with no `secret_env_bindings` key at all. - assert!(request.secret_env_bindings.is_empty()); + // bytes, with no `secret_bindings` key at all. + assert!(request.secret_bindings.is_empty()); assert_eq!( request.to_canonical_json().expect("canonical request"), fixture("remote_function_registration_request.canonical.json").trim() @@ -67,11 +67,11 @@ fn secret_bound_registration_request_matches_shared_canonical_golden() { .expect("registration request"); assert_eq!(request.name, "analyze_caption"); assert_eq!( - request.secret_env_bindings, - std::collections::BTreeMap::from([( - "OPENAI_API_KEY".to_string(), - "openai-prod".to_string() - )]) + request.secret_bindings, + [SecretBinding::Env { + variable: "OPENAI_API_KEY".to_string(), + secret_ref: "openai-prod".to_string(), + }] ); assert_eq!( request.to_canonical_json().expect("canonical request"), diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_job.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_job.json index 229511cee..8275e8de3 100644 --- a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_job.json +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_job.json @@ -3,7 +3,9 @@ "job_type": "create_function", "job_state": "DONE", "creation_ms": 1787270400000, - "spec": {"name": "embed"}, + "spec": { + "name": "embed" + }, "result": { "name": "embed", "version": "fv_01K3EXACT", @@ -13,19 +15,44 @@ "entrypoint": "embed" }, "signature": { - "inputs": [{"name": "text", "arrow_type": "utf8", "nullable": true}], - "output": {"kind": "scalar", "arrow_type": "list", "nullable": false} + "inputs": [ + { + "name": "text", + "arrow_type": "utf8", + "nullable": true + } + ], + "output": { + "kind": "scalar", + "arrow_type": "list", + "nullable": false + } }, "runtime": { "kind": "python", "python_version": "3.12", - "environment": {"kind": "pip", "packages": ["sentence-transformers>=3"]}, - "env": {"TOKENIZERS_PARALLELISM": "false"} + "environment": { + "kind": "pip", + "packages": [ + "sentence-transformers>=3" + ] + }, + "env": { + "TOKENIZERS_PARALLELISM": "false" + } }, "runtime_digest": "sha256:runtime", "environment_digest": "sha256:environment", - "secret_env_bindings": {"HF_TOKEN": "hf-prod"}, - "created_at": "2026-08-21T00:00:00Z" + "created_at": "2026-08-21T00:00:00Z", + "secret_bindings": [ + { + "kind": "env", + "variable": "HF_TOKEN", + "secret_ref": "hf-prod" + } + ] }, - "future_job": {"trace_id": "trace-1"} + "future_job": { + "trace_id": "trace-1" + } } diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_secret_registration_request.canonical.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_secret_registration_request.canonical.json index 3a879bccd..ac942a7f4 100644 --- a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_secret_registration_request.canonical.json +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_secret_registration_request.canonical.json @@ -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_env_bindings":{"OPENAI_API_KEY":"openai-prod"},"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":"openai-prod","variable":"OPENAI_API_KEY"}],"signature":{"inputs":[{"arrow_type":"utf8","name":"caption","nullable":false}],"output":{"arrow_type":"utf8","kind":"scalar","nullable":false}}} diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_secret_registration_request.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_secret_registration_request.json index 05ac5f348..7c926705f 100644 --- a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_secret_registration_request.json +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_secret_registration_request.json @@ -26,9 +26,6 @@ "kind": "python", "python_version": "3.12" }, - "secret_env_bindings": { - "OPENAI_API_KEY": "openai-prod" - }, "signature": { "inputs": [ { @@ -42,5 +39,12 @@ "kind": "scalar", "nullable": false } - } + }, + "secret_bindings": [ + { + "kind": "env", + "variable": "OPENAI_API_KEY", + "secret_ref": "openai-prod" + } + ] } diff --git a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_version.canonical.json b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_version.canonical.json index ef7868a33..c43482bed 100644 --- a/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_version.canonical.json +++ b/rust/lancedb/tests/fixtures/first_class_functions/v1/remote_function_version.canonical.json @@ -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_env_bindings":{"HF_TOKEN":"hf-prod"},"signature":{"inputs":[{"arrow_type":"utf8","name":"text","nullable":true}],"output":{"arrow_type":"list","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":"hf-prod","variable":"HF_TOKEN"}],"signature":{"inputs":[{"arrow_type":"utf8","name":"text","nullable":true}],"output":{"arrow_type":"list","kind":"scalar","nullable":false}},"version":"fv_01K3EXACT"}