salesforce-data-customcode 6.0.2.dev1__tar.gz → 6.0.3__tar.gz

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.
Files changed (97) hide show
  1. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/PKG-INFO +6 -3
  2. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/README.md +4 -2
  3. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/pyproject.toml +2 -1
  4. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/cli.py +6 -3
  5. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/client.py +23 -21
  6. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/deploy.py +109 -19
  7. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/io/writer/base.py +11 -1
  8. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/llm_gateway/__init__.py +2 -0
  9. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/llm_gateway/default.py +0 -2
  10. salesforce_data_customcode-6.0.3/src/datacustomcode/llm_gateway/errors.py +36 -0
  11. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/llm_gateway/spark_base.py +10 -3
  12. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/llm_gateway/spark_default.py +81 -16
  13. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/llm_gateway/types/generate_text_request.py +0 -7
  14. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/llm_gateway/types/generate_text_request_builder.py +0 -6
  15. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/templates/function/.devcontainer/devcontainer.json +1 -1
  16. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/templates/script/.devcontainer/devcontainer.json +1 -1
  17. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/templates/script/payload/entrypoint.py +5 -4
  18. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/token_provider.py +70 -36
  19. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/LICENSE.txt +0 -0
  20. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/__init__.py +0 -0
  21. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/auth.py +0 -0
  22. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/cmd.py +0 -0
  23. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/common_config.py +0 -0
  24. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/config.py +0 -0
  25. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/config.yaml +0 -0
  26. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/constants.py +0 -0
  27. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/credentials.py +0 -0
  28. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/einstein_platform_client.py +0 -0
  29. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/einstein_platform_config.py +0 -0
  30. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/einstein_predictions/__init__.py +0 -0
  31. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/einstein_predictions/base.py +0 -0
  32. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/einstein_predictions/impl/default.py +0 -0
  33. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/einstein_predictions/types.py +0 -0
  34. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/einstein_predictions_config.py +0 -0
  35. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/file/__init__.py +0 -0
  36. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/file/base.py +0 -0
  37. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/file/path/__init__.py +0 -0
  38. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/file/path/default.py +0 -0
  39. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/function/__init__.py +0 -0
  40. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/function/base.py +0 -0
  41. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/function/feature_types/__init__.py +0 -0
  42. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/function/feature_types/chunking.py +0 -0
  43. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/function/runtime.py +0 -0
  44. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/function_utils.py +0 -0
  45. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/io/__init__.py +0 -0
  46. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/io/base.py +0 -0
  47. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/io/reader/__init__.py +0 -0
  48. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/io/reader/base.py +0 -0
  49. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/io/reader/query_api.py +0 -0
  50. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/io/reader/sf_cli.py +0 -0
  51. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/io/reader/utils.py +0 -0
  52. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/io/writer/__init__.py +0 -0
  53. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/io/writer/csv.py +0 -0
  54. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/io/writer/print.py +0 -0
  55. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/llm_gateway/base.py +0 -0
  56. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/llm_gateway/types/__init__.py +0 -0
  57. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/llm_gateway/types/generate_text_response.py +0 -0
  58. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/llm_gateway/types/generate_text_response_builder.py +0 -0
  59. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/llm_gateway_config.py +0 -0
  60. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/mixin.py +0 -0
  61. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/py.typed +0 -0
  62. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/run.py +0 -0
  63. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/scan.py +0 -0
  64. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/spark/__init__.py +0 -0
  65. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/spark/base.py +0 -0
  66. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/spark/default.py +0 -0
  67. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/template.py +0 -0
  68. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/templates/__init__.py +0 -0
  69. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/templates/function/Dockerfile.dependencies +0 -0
  70. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/templates/function/README.md +0 -0
  71. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/templates/function/build_native_dependencies.sh +0 -0
  72. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/templates/function/chunking/payload/config.json +0 -0
  73. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/templates/function/chunking/payload/entrypoint.py +0 -0
  74. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/templates/function/chunking/requirements.txt +0 -0
  75. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/templates/function/example/chunking_with_llm/config.json +0 -0
  76. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/templates/function/example/chunking_with_llm/entrypoint.py +0 -0
  77. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/templates/function/example/chunking_with_llm/files/chunking_prompt.txt +0 -0
  78. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/templates/function/example/chunking_with_llm/tests/test.json +0 -0
  79. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/templates/function/example/chunking_with_prediction/config.json +0 -0
  80. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/templates/function/example/chunking_with_prediction/entrypoint.py +0 -0
  81. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/templates/function/example/chunking_with_prediction/tests/test.json +0 -0
  82. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/templates/function/payload/config.json +0 -0
  83. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/templates/function/payload/entrypoint.py +0 -0
  84. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/templates/function/requirements-dev.txt +0 -0
  85. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/templates/function/requirements.txt +0 -0
  86. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/templates/script/Dockerfile +0 -0
  87. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/templates/script/Dockerfile.dependencies +0 -0
  88. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/templates/script/README.md +0 -0
  89. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/templates/script/account.ipynb +0 -0
  90. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/templates/script/build_native_dependencies.sh +0 -0
  91. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/templates/script/examples/employee_hierarchy/employee_data.csv +0 -0
  92. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/templates/script/examples/employee_hierarchy/entrypoint.py +0 -0
  93. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/templates/script/jupyterlab.sh +0 -0
  94. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/templates/script/payload/config.json +0 -0
  95. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/templates/script/requirements-dev.txt +0 -0
  96. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/templates/script/requirements.txt +0 -0
  97. {salesforce_data_customcode-6.0.2.dev1 → salesforce_data_customcode-6.0.3}/src/datacustomcode/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: salesforce-data-customcode
3
- Version: 6.0.2.dev1
3
+ Version: 6.0.3
4
4
  Summary: Data Cloud Custom Code SDK
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE.txt
@@ -15,6 +15,7 @@ Requires-Dist: click (>=8.1.8,<9.0.0)
15
15
  Requires-Dist: loguru (>=0.7.3,<0.8.0)
16
16
  Requires-Dist: numpy
17
17
  Requires-Dist: pandas
18
+ Requires-Dist: pipreqs
18
19
  Requires-Dist: pydantic (==2.13.1)
19
20
  Requires-Dist: pyspark (==3.5.1)
20
21
  Requires-Dist: pyyaml (>=6.0,<7.0)
@@ -341,14 +342,16 @@ from datacustomcode.client import Client, llm_gateway_generate_text_col
341
342
  def main():
342
343
  client = Client()
343
344
  df = client.read_dlo("Input__dll")
345
+ # llm_gateway_generate_text_col returns a struct
346
+ # {status, response, error_code, error_message} per row, so per-row
347
+ # failures don't abort the Spark job. Pick the field you want with [].
344
348
  df_generated = df.withColumn(
345
349
  "greeting__c",
346
350
  llm_gateway_generate_text_col(
347
351
  "In one sentence, greet {name} from {city}.",
348
352
  {"name": col("name__c"), "city": col("homecity__c")},
349
353
  model_id="sfdc_ai__DefaultGPT4Omni", # An AI model in your org
350
- max_tokens=100,
351
- ),
354
+ )["response"],
352
355
  )
353
356
 
354
357
  dlo_name = "Output_dll"
@@ -316,14 +316,16 @@ from datacustomcode.client import Client, llm_gateway_generate_text_col
316
316
  def main():
317
317
  client = Client()
318
318
  df = client.read_dlo("Input__dll")
319
+ # llm_gateway_generate_text_col returns a struct
320
+ # {status, response, error_code, error_message} per row, so per-row
321
+ # failures don't abort the Spark job. Pick the field you want with [].
319
322
  df_generated = df.withColumn(
320
323
  "greeting__c",
321
324
  llm_gateway_generate_text_col(
322
325
  "In one sentence, greet {name} from {city}.",
323
326
  {"name": col("name__c"), "city": col("homecity__c")},
324
327
  model_id="sfdc_ai__DefaultGPT4Omni", # An AI model in your org
325
- max_tokens=100,
326
- ),
328
+ )["response"],
327
329
  )
328
330
 
329
331
  dlo_name = "Output_dll"
@@ -18,7 +18,7 @@ license = "Apache-2.0"
18
18
  name = "salesforce-data-customcode"
19
19
  readme = "README.md"
20
20
  requires-python = ">=3.10,<3.12"
21
- version = "6.0.2.dev1"
21
+ version = "6.0.3"
22
22
 
23
23
  [tool.black]
24
24
  exclude = '''
@@ -101,6 +101,7 @@ click = "^8.1.8"
101
101
  loguru = "^0.7.3"
102
102
  numpy = "*"
103
103
  pandas = "*"
104
+ pipreqs = "*"
104
105
  pydantic = "2.13.1"
105
106
  pyspark = "3.5.1"
106
107
  python = ">=3.10,<3.12"
@@ -198,6 +198,7 @@ def zip(path: str, network: str):
198
198
  default=None,
199
199
  help="SF CLI org alias or username. Fetches credentials via `sf org display`.",
200
200
  )
201
+ @click.option("--use-in-feature", default=None, hidden=True, deprecated=True)
201
202
  def deploy(
202
203
  path: str,
203
204
  name: str,
@@ -207,6 +208,7 @@ def deploy(
207
208
  profile: str,
208
209
  network: str,
209
210
  sf_cli_org: Optional[str],
211
+ use_in_feature: Optional[str],
210
212
  ):
211
213
  from datacustomcode.constants import USE_IN_FEATURE_MAPPING_FOR_CONNECT_API
212
214
  from datacustomcode.deploy import (
@@ -249,13 +251,14 @@ def deploy(
249
251
  logger.info(f"Inferred use_in_feature: {use_in_feature}")
250
252
  else:
251
253
  click.secho(
252
- "Error: Could not infer function invoke options. "
253
- "Please provide --use-in-feature",
254
+ "Error: Function signature does not match a supported type. "
255
+ "Use SearchIndexChunkingV1Request and "
256
+ "SearchIndexChunkingV1Response in function signature.",
254
257
  fg="red",
255
258
  )
256
259
  raise click.Abort()
257
260
 
258
- # Map user-provided feature names to API names
261
+ # Map feature names to Connect API names
259
262
  mapped_feature = USE_IN_FEATURE_MAPPING_FOR_CONNECT_API.get(
260
263
  use_in_feature, use_in_feature
261
264
  )
@@ -59,20 +59,26 @@ def llm_gateway_generate_text_col(
59
59
  template: str,
60
60
  values: Union[Dict[str, "Column"], "Column"],
61
61
  model_id: Optional[str] = None,
62
- max_tokens: Optional[int] = None,
63
62
  ) -> "Column":
64
63
  """Build a Spark Column that runs the LLM Gateway per row.
65
64
 
65
+ The returned Column yields a struct ``{status, response, error_code,
66
+ error_message}`` for each row. Use ``[...]`` (or ``getField``) to pick the
67
+ field you want, e.g. ``llm_gateway_generate_text_col(...)["response"]``.
68
+ Per-row failures populate ``status`` / ``error_code`` / ``error_message``
69
+ so a single bad row does not abort the whole Spark job.
70
+
66
71
  Example:
67
72
 
68
- >>> df.withColumn(
69
- ... "greeting__c",
70
- ... llm_gateway_generate_text_col(
71
- ... "In one sentence, greet {name} from {city}.",
72
- ... {"name": col("name__c"), "city": col("homecity__c")},
73
- ... model_id="sfdc_ai__DefaultGPT4Omni",
74
- ... max_tokens=100,
75
- ... ),
73
+ >>> result = llm_gateway_generate_text_col(
74
+ ... "In one sentence, greet {name} from {city}.",
75
+ ... {"name": col("name__c"), "city": col("homecity__c")},
76
+ ... model_id="sfdc_ai__DefaultGPT4Omni",
77
+ ... )
78
+ >>> df.withColumn("greeting__c", result["response"])
79
+ >>> # …or keep the struct around and inspect failures:
80
+ >>> df.withColumn("llm", result).select(
81
+ ... "llm.status", "llm.response", "llm.error_message"
76
82
  ... )
77
83
 
78
84
  Args:
@@ -81,15 +87,16 @@ def llm_gateway_generate_text_col(
81
87
  values: Either a mapping from placeholder name to Spark ``Column``, or
82
88
  a single ``Column`` whose value is already a struct.
83
89
  model_id: LLM model id. Defaults to ``sfdc_ai__DefaultGPT4Omni``.
84
- max_tokens: Maximum tokens to generate. Defaults to 200.
85
90
 
86
91
  Returns:
87
- A Spark ``Column`` that, when evaluated, produces the generated text.
92
+ A Spark ``Column`` of ``StructType`` with fields ``status``,
93
+ ``response``, ``error_code``, and ``error_message`` (all nullable
94
+ strings). On success, ``status == "SUCCESS"`` and ``response`` holds
95
+ the generated text; on failure, ``status == "ERROR"`` and the
96
+ ``error_*`` fields carry diagnostic detail.
88
97
  """
89
98
  gateway = Client()._get_spark_llm_gateway()
90
- return gateway.llm_gateway_generate_text_col(
91
- template, values, model_id=model_id, max_tokens=max_tokens
92
- )
99
+ return gateway.llm_gateway_generate_text_col(template, values, model_id=model_id)
93
100
 
94
101
 
95
102
  class DataCloudObjectType(Enum):
@@ -150,9 +157,7 @@ class Client:
150
157
  finder: Find a file path
151
158
  reader: A custom reader to use for reading Data Cloud objects.
152
159
  writer: A custom writer to use for writing Data Cloud objects.
153
- spark_llm_gateway: Optional custom :class:`SparkLLMGateway`. When
154
- omitted, the gateway is lazily resolved from
155
- ``spark_llm_gateway_config``.
160
+ spark_llm_gateway: Optional custom :class:`SparkLLMGateway`.
156
161
 
157
162
  Example:
158
163
  >>> client = Client()
@@ -292,7 +297,6 @@ class Client:
292
297
  self,
293
298
  prompt: str,
294
299
  model_id: Optional[str] = None,
295
- max_tokens: Optional[int] = None,
296
300
  ) -> str:
297
301
  """Issue a one-shot LLM Gateway call. This is the scalar counterpart to
298
302
  :func:`llm_gateway_generate_text_col`: it runs **once** — not per row.
@@ -310,15 +314,13 @@ class Client:
310
314
  ``{field}`` substitution is performed on this string.
311
315
  model_id: LLM model id to target. Defaults to
312
316
  ``sfdc_ai__DefaultGPT4Omni`` when ``None``.
313
- max_tokens: Hard upper bound on the number of tokens the model
314
- may generate. Defaults to 200 when ``None``.
315
317
 
316
318
  Returns:
317
319
  The generated text as a plain Python ``str``; empty when the
318
320
  gateway response carries no generated text.
319
321
  """
320
322
  return self._get_spark_llm_gateway().llm_gateway_generate_text(
321
- prompt, model_id=model_id, max_tokens=max_tokens
323
+ prompt, model_id=model_id
322
324
  )
323
325
 
324
326
  def _get_spark_llm_gateway(self) -> SparkLLMGateway:
@@ -26,12 +26,13 @@ from typing import (
26
26
  Callable,
27
27
  Dict,
28
28
  List,
29
+ Optional,
29
30
  Union,
30
31
  )
31
32
 
32
33
  from loguru import logger
33
34
  import pydantic
34
- from pydantic import BaseModel
35
+ from pydantic import BaseModel, model_validator
35
36
  import requests
36
37
 
37
38
  from datacustomcode.cmd import cmd_output
@@ -366,23 +367,86 @@ class BaseConfig(BaseModel):
366
367
  entryPoint: str
367
368
 
368
369
 
370
+ class DataObjectField(BaseModel):
371
+ name: str
372
+ label: str
373
+ dataType: str
374
+ isPrimaryKey: bool = False
375
+ keyQualifierFieldName: Optional[str] = None
376
+
377
+
378
+ class DataObject(BaseModel):
379
+ name: str
380
+ label: str
381
+ type: str
382
+ category: str
383
+ fields: list[DataObjectField]
384
+
385
+
369
386
  class DataTransformConfig(BaseConfig):
370
387
  sdkVersion: str
371
388
  dataspace: str
372
389
  permissions: Permissions
390
+ dataObjects: Optional[list[DataObject]] = None
373
391
 
374
392
 
375
393
  class FunctionConfig(BaseConfig):
376
394
  pass
377
395
 
378
396
 
397
+ class DloPermission(BaseModel):
398
+ dlo: list[str]
399
+
400
+
401
+ class DmoPermission(BaseModel):
402
+ dmo: list[str]
403
+
404
+
379
405
  class Permissions(BaseModel):
380
- read: Union[DloPermission]
381
- write: Union[DloPermission]
406
+ read: Union[DloPermission, DmoPermission]
407
+ write: Union[DloPermission, DmoPermission]
408
+
409
+ @model_validator(mode="after")
410
+ def _no_mixed_layers(self) -> "Permissions":
411
+ read_is_dlo = isinstance(self.read, DloPermission)
412
+ write_is_dlo = isinstance(self.write, DloPermission)
413
+ if read_is_dlo != write_is_dlo:
414
+ raise ValueError(
415
+ "permissions.read and permissions.write must both reference "
416
+ "DLOs or both reference DMOs (got "
417
+ f"read={type(self.read).__name__}, "
418
+ f"write={type(self.write).__name__})"
419
+ )
420
+ return self
382
421
 
383
422
 
384
- class DloPermission(BaseModel):
385
- dlo: list[str]
423
+ def _permission_entries(perm: Union[DloPermission, DmoPermission]) -> list[str]:
424
+ """Return the list of object names regardless of layer (DLO or DMO)."""
425
+ if isinstance(perm, DloPermission):
426
+ return perm.dlo
427
+ return perm.dmo
428
+
429
+
430
+ def _data_object_to_output(obj: DataObject) -> dict[str, Any]:
431
+ """Convert a config.json DataObject into an outputDataObjects entry."""
432
+ fields: list[dict[str, Any]] = []
433
+ for field in obj.fields:
434
+ entry: dict[str, Any] = {
435
+ "isPrimaryKey": field.isPrimaryKey,
436
+ "label": field.label,
437
+ "name": field.name,
438
+ "type": field.dataType,
439
+ }
440
+ if field.keyQualifierFieldName is not None:
441
+ entry["keyQualifierField"] = field.keyQualifierFieldName
442
+ fields.append(entry)
443
+ return {
444
+ "category": obj.category,
445
+ "fields": fields,
446
+ "label": obj.label,
447
+ "name": obj.name,
448
+ "type": obj.type,
449
+ }
386
450
 
387
451
 
388
452
  def get_config(directory: str) -> BaseConfig:
@@ -404,10 +468,17 @@ def get_config(directory: str) -> BaseConfig:
404
468
  except json.JSONDecodeError as err:
405
469
  raise ValueError(f"config.json at {config_path} is not valid JSON") from err
406
470
  except pydantic.ValidationError as err:
407
- missing_fields = [str(err["loc"][0]) for err in err.errors()]
471
+ errors = err.errors()
472
+ missing = [e for e in errors if e.get("type") == "missing"]
473
+ if missing and len(missing) == len(errors):
474
+ missing_fields = [str(e["loc"][0]) for e in missing]
475
+ raise ValueError(
476
+ f"config.json at {config_path} is missing required "
477
+ f"fields: {', '.join(missing_fields)}"
478
+ ) from err
479
+ messages = [str(e.get("msg", "")) for e in errors]
408
480
  raise ValueError(
409
- f"config.json at {config_path} is missing required "
410
- f"fields: {', '.join(missing_fields)}"
481
+ f"config.json at {config_path} is invalid: {'; '.join(messages)}"
411
482
  ) from err
412
483
 
413
484
 
@@ -421,26 +492,45 @@ def create_data_transform(
421
492
  script_name = metadata.name
422
493
  request_hydrated = DATA_TRANSFORM_REQUEST_TEMPLATE.copy()
423
494
 
424
- # Add nodes for each write DLO
425
- for i, dlo in enumerate(data_transform_config.permissions.write.dlo, 1):
495
+ # Add nodes for each write entry (DLO or DMO)
496
+ for i, name in enumerate(
497
+ _permission_entries(data_transform_config.permissions.write), 1
498
+ ):
426
499
  request_hydrated["nodes"][f"node{i}"] = {
427
- "relation_name": dlo,
500
+ "relation_name": name,
428
501
  "config": {"materialized": "table"},
429
502
  "compiled_code": "",
430
503
  }
431
504
 
432
- # Add sources for each read DLO
433
- for i, dlo in enumerate(data_transform_config.permissions.read.dlo, 1):
434
- request_hydrated["sources"][f"source{i}"] = {"relation_name": dlo}
505
+ # Add sources for each read entry (DLO or DMO)
506
+ for i, name in enumerate(
507
+ _permission_entries(data_transform_config.permissions.read), 1
508
+ ):
509
+ request_hydrated["sources"][f"source{i}"] = {"relation_name": name}
435
510
 
436
511
  request_hydrated["macros"]["macro.byoc"]["arguments"][0]["name"] = script_name
437
512
 
513
+ definition: dict[str, Any] = {
514
+ "type": "DCSQL",
515
+ "manifest": request_hydrated,
516
+ "version": "56.0",
517
+ }
518
+
519
+ # outputDataObjects is only set for DMO-backed transforms. The server requires
520
+ # the schema of any DMO created/updated by the transform; DLO transforms use
521
+ # an existing materialized table and must not include this field.
522
+ if isinstance(data_transform_config.permissions.write, DmoPermission):
523
+ if not data_transform_config.dataObjects:
524
+ raise ValueError(
525
+ "DMO transforms require 'dataObjects' in config.json describing "
526
+ "the schema of each output DMO."
527
+ )
528
+ definition["outputDataObjects"] = [
529
+ _data_object_to_output(obj) for obj in data_transform_config.dataObjects
530
+ ]
531
+
438
532
  body = {
439
- "definition": {
440
- "type": "DCSQL",
441
- "manifest": request_hydrated,
442
- "version": "56.0",
443
- },
533
+ "definition": definition,
444
534
  "label": f"{metadata.name}",
445
535
  "name": f"{metadata.name}",
446
536
  "type": "BATCH",
@@ -27,11 +27,21 @@ if TYPE_CHECKING:
27
27
  class WriteMode(str, Enum):
28
28
  APPEND = "append"
29
29
  OVERWRITE = "overwrite"
30
- OVERWRITE_PARTITIONS = "overwrite_partitions"
30
+ OVERWRITE_PARTITIONS = "overwrite_partitions" # Deprecated: raises error if used
31
31
  MERGE = "merge"
32
32
  MERGE_UPSERT_DELETE = "merge_upsert_delete"
33
33
 
34
34
 
35
+ class MergeRecordType(str, Enum):
36
+ """Values for the _merge_record_type column used by MERGE_UPSERT_DELETE."""
37
+
38
+ UPSERT = "UPSERT"
39
+ DELETE = "DELETE"
40
+
41
+
42
+ MERGE_RECORD_TYPE_COLUMN = "_merge_record_type"
43
+
44
+
35
45
  class BaseDataCloudWriter(BaseDataAccessLayer):
36
46
  """Base class for Data Cloud writers."""
37
47
 
@@ -15,6 +15,7 @@
15
15
 
16
16
  from datacustomcode.llm_gateway.base import LLMGateway
17
17
  from datacustomcode.llm_gateway.default import DefaultLLMGateway
18
+ from datacustomcode.llm_gateway.errors import LLMGatewayCallError
18
19
  from datacustomcode.llm_gateway.spark_base import SparkLLMGateway
19
20
  from datacustomcode.llm_gateway.spark_default import DefaultSparkLLMGateway
20
21
 
@@ -22,5 +23,6 @@ __all__ = [
22
23
  "DefaultLLMGateway",
23
24
  "DefaultSparkLLMGateway",
24
25
  "LLMGateway",
26
+ "LLMGatewayCallError",
25
27
  "SparkLLMGateway",
26
28
  ]
@@ -34,8 +34,6 @@ class DefaultLLMGateway(EinsteinPlatformClient, LLMGateway):
34
34
 
35
35
  payload: Dict[str, Any] = {"prompt": request.prompt}
36
36
 
37
- if request.max_tokens is not None:
38
- payload["max_tokens"] = request.max_tokens
39
37
  if request.localization:
40
38
  payload["localization"] = request.localization
41
39
  if request.tags:
@@ -0,0 +1,36 @@
1
+ # Copyright (c) 2025, Salesforce, Inc.
2
+ # SPDX-License-Identifier: Apache-2
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ """Exceptions raised by LLM Gateway implementations."""
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Optional
20
+
21
+
22
+ class LLMGatewayCallError(RuntimeError):
23
+ """Raised when an LLM Gateway call returns an error."""
24
+
25
+ def __init__(
26
+ self,
27
+ message: str,
28
+ *,
29
+ status: Optional[object] = None,
30
+ error_code: Optional[str] = None,
31
+ error_message: Optional[str] = None,
32
+ ) -> None:
33
+ super().__init__(message)
34
+ self.status = status
35
+ self.error_code = error_code
36
+ self.error_message = error_message
@@ -40,7 +40,6 @@ class SparkLLMGateway(ABC, UserExtendableNamedConfigMixin):
40
40
  self,
41
41
  prompt: str,
42
42
  model_id: Optional[str] = None,
43
- max_tokens: Optional[int] = None,
44
43
  ) -> str:
45
44
  """Issue a one-shot LLM Gateway call and return the generated text."""
46
45
 
@@ -50,6 +49,14 @@ class SparkLLMGateway(ABC, UserExtendableNamedConfigMixin):
50
49
  template: str,
51
50
  values: Union[Dict[str, "Column"], "Column"],
52
51
  model_id: Optional[str] = None,
53
- max_tokens: Optional[int] = None,
54
52
  ) -> "Column":
55
- """Build a Spark ``Column`` that invokes the LLM Gateway per row."""
53
+ """Build a Spark ``Column`` that invokes the LLM Gateway per row and
54
+ yields a struct ``{status, response, error_code, error_message}``.
55
+
56
+ Select an individual field, e.g.
57
+ ``llm_gateway_generate_text_col(...)["response"]``. Returning a struct
58
+ means a single failing row doesn't abort the Spark job.
59
+ Failing row leaves the rest of the DataFrame intact — callers can
60
+ inspect ``status`` / ``error_code`` per row instead of having the
61
+ Spark job abort.
62
+ """
@@ -28,10 +28,15 @@ if TYPE_CHECKING:
28
28
  from pyspark.sql import Column
29
29
 
30
30
  from datacustomcode.llm_gateway.base import LLMGateway
31
+ from datacustomcode.llm_gateway.types.generate_text_response import (
32
+ GenerateTextResponse,
33
+ )
31
34
 
32
35
 
33
36
  _DEFAULT_LLM_MODEL_ID = "sfdc_ai__DefaultGPT4Omni"
34
- _DEFAULT_LLM_MAX_TOKENS = 200
37
+
38
+ _STATUS_SUCCESS = "SUCCESS"
39
+ _STATUS_ERROR = "ERROR"
35
40
 
36
41
 
37
42
  class DefaultSparkLLMGateway(SparkLLMGateway):
@@ -52,20 +57,26 @@ class DefaultSparkLLMGateway(SparkLLMGateway):
52
57
  self,
53
58
  prompt: str,
54
59
  model_id: Optional[str] = None,
55
- max_tokens: Optional[int] = None,
56
60
  ) -> str:
57
- return _invoke_llm_gateway(self._llm_gateway, prompt, model_id, max_tokens)
61
+ return _invoke_llm_gateway(self._llm_gateway, prompt, model_id)
58
62
 
59
63
  def llm_gateway_generate_text_col(
60
64
  self,
61
65
  template: str,
62
66
  values: Union[Dict[str, "Column"], "Column"],
63
67
  model_id: Optional[str] = None,
64
- max_tokens: Optional[int] = None,
65
68
  ) -> "Column":
66
-
69
+ """Build a per-row UDF that returns a struct ``{status, response,
70
+ error_code, error_message}`` so per-row failures do not abort the
71
+ Spark job. Callers select the field they want, e.g.
72
+ ``llm_gateway_generate_text_col(...)["response"]``.
73
+ """
67
74
  from pyspark.sql.functions import struct, udf
68
- from pyspark.sql.types import StringType
75
+ from pyspark.sql.types import (
76
+ StringType,
77
+ StructField,
78
+ StructType,
79
+ )
69
80
 
70
81
  if isinstance(values, dict):
71
82
  values_col = struct(*[v.alias(k) for k, v in values.items()])
@@ -73,19 +84,32 @@ class DefaultSparkLLMGateway(SparkLLMGateway):
73
84
  values_col = values
74
85
 
75
86
  gateway = self._llm_gateway
87
+ result_schema = StructType(
88
+ [
89
+ StructField("status", StringType(), True),
90
+ StructField("response", StringType(), True),
91
+ StructField("error_code", StringType(), True),
92
+ StructField("error_message", StringType(), True),
93
+ ]
94
+ )
76
95
 
77
- def _generate(values_row: Any) -> str:
96
+ def _generate(values_row: Any) -> Dict[str, Optional[str]]:
78
97
  if values_row is None:
79
- return ""
98
+ return {
99
+ "status": _STATUS_ERROR,
100
+ "response": None,
101
+ "error_code": None,
102
+ "error_message": "values column was null for this row",
103
+ }
80
104
  subs = (
81
105
  values_row.asDict()
82
106
  if hasattr(values_row, "asDict")
83
107
  else dict(values_row)
84
108
  )
85
109
  prompt = template.format(**subs)
86
- return _invoke_llm_gateway(gateway, prompt, model_id, max_tokens)
110
+ return _invoke_llm_gateway_as_struct(gateway, prompt, model_id)
87
111
 
88
- return udf(_generate, StringType())(values_col)
112
+ return udf(_generate, result_schema)(values_col)
89
113
 
90
114
 
91
115
  def _build_underlying_gateway() -> "LLMGateway":
@@ -100,20 +124,61 @@ def _build_underlying_gateway() -> "LLMGateway":
100
124
  return cfg.to_object()
101
125
 
102
126
 
103
- def _invoke_llm_gateway(
127
+ def _call_llm_gateway(
104
128
  gateway: "LLMGateway",
105
129
  prompt: str,
106
130
  model_id: Optional[str],
107
- max_tokens: Optional[int],
108
- ) -> str:
131
+ ) -> "GenerateTextResponse":
132
+ """Build the request and dispatch it to the underlying gateway."""
109
133
  from datacustomcode.llm_gateway.types.generate_text_request_builder import (
110
134
  GenerateTextRequestBuilder,
111
135
  )
112
136
 
113
- builder = (
137
+ request = (
114
138
  GenerateTextRequestBuilder()
115
139
  .set_prompt(prompt)
116
140
  .set_model(model_id or _DEFAULT_LLM_MODEL_ID)
117
- .set_max_tokens(max_tokens or _DEFAULT_LLM_MAX_TOKENS)
141
+ .build()
118
142
  )
119
- return gateway.generate_text(builder.build()).text
143
+ return gateway.generate_text(request)
144
+
145
+
146
+ def _invoke_llm_gateway(
147
+ gateway: "LLMGateway",
148
+ prompt: str,
149
+ model_id: Optional[str],
150
+ ) -> str:
151
+ from datacustomcode.llm_gateway.errors import LLMGatewayCallError
152
+
153
+ response = _call_llm_gateway(gateway, prompt, model_id)
154
+ if response.is_error:
155
+ raise LLMGatewayCallError(
156
+ f"LLM Gateway call failed: status_code={response.status_code}, "
157
+ f"error_code={response.error_code!r}, "
158
+ f"message={response.data!r}",
159
+ status=response.status_code,
160
+ error_code=response.error_code or None,
161
+ error_message=str(response.data) if response.data else None,
162
+ )
163
+ return response.text
164
+
165
+
166
+ def _invoke_llm_gateway_as_struct(
167
+ gateway: "LLMGateway",
168
+ prompt: str,
169
+ model_id: Optional[str],
170
+ ) -> Dict[str, Optional[str]]:
171
+ response = _call_llm_gateway(gateway, prompt, model_id)
172
+ if response.is_error:
173
+ return {
174
+ "status": _STATUS_ERROR,
175
+ "response": None,
176
+ "error_code": response.error_code or None,
177
+ "error_message": str(response.data) if response.data else None,
178
+ }
179
+ return {
180
+ "status": _STATUS_SUCCESS,
181
+ "response": response.text,
182
+ "error_code": None,
183
+ "error_message": None,
184
+ }
@@ -40,13 +40,6 @@ class GenerateTextRequest(BaseModel):
40
40
  )
41
41
  model_name: str = Field(..., min_length=1, description="Name of the model to use")
42
42
  prompt: str = Field(..., description="Input prompt")
43
- max_tokens: Optional[int] = Field(
44
- default=None,
45
- ge=1,
46
- description=(
47
- "Maximum number of tokens to generate. If None, server default applies."
48
- ),
49
- )
50
43
  localization: Optional[Dict[str, Any]] = Field(
51
44
  default=None, description="Localization settings"
52
45
  )
@@ -26,7 +26,6 @@ class GenerateTextRequestBuilder:
26
26
  def __init__(self) -> None:
27
27
  self._prompt = ""
28
28
  self._model_name = ""
29
- self._max_tokens: Optional[int] = None
30
29
  self._localization: Optional[Dict[str, Any]] = None
31
30
  self._tags: Optional[Dict[str, Any]] = None
32
31
 
@@ -38,10 +37,6 @@ class GenerateTextRequestBuilder:
38
37
  self._model_name = model_name
39
38
  return self
40
39
 
41
- def set_max_tokens(self, max_tokens: int) -> "GenerateTextRequestBuilder":
42
- self._max_tokens = max_tokens
43
- return self
44
-
45
40
  def set_localization(
46
41
  self,
47
42
  localization: Optional[Dict[str, Any]] = None,
@@ -80,7 +75,6 @@ class GenerateTextRequestBuilder:
80
75
  request = GenerateTextRequest(
81
76
  prompt=self._prompt,
82
77
  model_name=self._model_name,
83
- max_tokens=self._max_tokens,
84
78
  localization=self._localization,
85
79
  tags=self._tags,
86
80
  )
@@ -5,6 +5,6 @@
5
5
  "dockerfile": "../Dockerfile"
6
6
  },
7
7
  "features": {
8
- "ghcr.io/devcontainers/features/git:1": {},
8
+ "ghcr.io/devcontainers/features/git:1": {}
9
9
  }
10
10
  }
@@ -5,6 +5,6 @@
5
5
  "dockerfile": "../Dockerfile"
6
6
  },
7
7
  "features": {
8
- "ghcr.io/devcontainers/features/git:1": {},
8
+ "ghcr.io/devcontainers/features/git:1": {}
9
9
  }
10
10
  }
@@ -16,7 +16,9 @@ def main():
16
16
  You can use your AI models configured in Salesforce to generate column
17
17
  values. See README.md for how to test locally before deploying to Data Cloud.
18
18
 
19
- Example:
19
+ Example (the per-row helper returns a struct
20
+ ``{status, response, error_code, error_message}`` — pick the field you
21
+ want with ``[...]``):
20
22
 
21
23
  >>> from datacustomcode.client import llm_gateway_generate_text_col
22
24
  df_generated = df.withColumn(
@@ -25,8 +27,7 @@ def main():
25
27
  ... "In one sentence, greet {name} from {city}.",
26
28
  ... {"name": col("name__c"), "city": col("homecity__c")},
27
29
  ... model_id="sfdc_ai__DefaultGPT4Omni",
28
- ... max_tokens=100,
29
- ... ),
30
+ ... )["response"],
30
31
  ... )
31
32
 
32
33
  You can also invoke the LLM with a literal plain text prompt — no
@@ -35,7 +36,7 @@ def main():
35
36
  Example:
36
37
 
37
38
  >>> generated_text = client.llm_gateway_generate_text(
38
- ... prompt, model_id, max_tokens
39
+ ... prompt, model_id
39
40
  ... )
40
41
  """
41
42
 
@@ -101,49 +101,83 @@ class SFCLITokenProvider(TokenProvider):
101
101
 
102
102
  from datacustomcode.deploy import AccessTokenResponse
103
103
 
104
- try:
105
- result = subprocess.run(
106
- ["sf", "org", "display", "--target-org", self.sf_cli_org, "--json"],
107
- capture_output=True,
108
- text=True,
109
- check=True,
110
- timeout=30,
111
- )
112
- except FileNotFoundError as exc:
113
- raise RuntimeError(
114
- "The 'sf' command was not found. "
115
- "Install Salesforce CLI: https://developer.salesforce.com/tools/salesforcecli"
116
- ) from exc
117
- except subprocess.TimeoutExpired as exc:
118
- raise RuntimeError(
119
- f"'sf org display' timed out for org '{self.sf_cli_org}'"
120
- ) from exc
121
- except subprocess.CalledProcessError as exc:
122
- raise RuntimeError(
123
- f"'sf org display' failed for org '{self.sf_cli_org}': {exc.stderr}"
124
- ) from exc
104
+ def _run_sf_command(args: list[str], description: str) -> dict:
105
+ try:
106
+ result = subprocess.run(
107
+ args,
108
+ capture_output=True,
109
+ text=True,
110
+ check=True,
111
+ timeout=30,
112
+ )
113
+ except FileNotFoundError as exc:
114
+ raise RuntimeError(
115
+ "The 'sf' command was not found. "
116
+ "Install Salesforce CLI: "
117
+ "https://developer.salesforce.com/tools/salesforcecli"
118
+ ) from exc
119
+ except subprocess.TimeoutExpired as exc:
120
+ raise RuntimeError(
121
+ f"'{description}' timed out for org '{self.sf_cli_org}'"
122
+ ) from exc
123
+ except subprocess.CalledProcessError as exc:
124
+ raise RuntimeError(
125
+ f"'{description}' failed for org '{self.sf_cli_org}': "
126
+ f"{exc.stderr}"
127
+ ) from exc
125
128
 
126
- try:
127
- data = json.loads(result.stdout)
128
- except json.JSONDecodeError as exc:
129
- raise RuntimeError(
130
- f"Failed to parse JSON from 'sf org display': {result.stdout}"
131
- ) from exc
129
+ try:
130
+ data = json.loads(result.stdout)
131
+ except json.JSONDecodeError as exc:
132
+ raise RuntimeError(
133
+ f"Failed to parse JSON from '{description}': {result.stdout}"
134
+ ) from exc
132
135
 
133
- if data.get("status") != 0:
136
+ if data.get("status") != 0:
137
+ raise RuntimeError(
138
+ f"SF CLI error for org '{self.sf_cli_org}': "
139
+ f"{data.get('message', 'unknown error')}"
140
+ )
141
+ return dict(data)
142
+
143
+ # Get org info from sf org display
144
+ display_data = _run_sf_command(
145
+ ["sf", "org", "display", "--target-org", self.sf_cli_org, "--json"],
146
+ "sf org display",
147
+ )
148
+ result_data = display_data.get("result", {})
149
+ instance_url = result_data.get("instanceUrl")
150
+ if not instance_url:
134
151
  raise RuntimeError(
135
- f"SF CLI error for org '{self.sf_cli_org}': "
136
- f"{data.get('message', 'unknown error')}"
152
+ f"'sf org display' did not return an instance URL "
153
+ f"for org '{self.sf_cli_org}'"
137
154
  )
138
155
 
139
- result_data = data.get("result", {})
140
- access_token = result_data.get("accessToken")
141
- instance_url = result_data.get("instanceUrl")
156
+ # Try show-access-token first (SF CLI >= 2.136.6); fall back to the
157
+ # token from sf org display (older CLIs don't redact it).
158
+ access_token = None
159
+ try:
160
+ token_data = _run_sf_command(
161
+ [
162
+ "sf",
163
+ "org",
164
+ "auth",
165
+ "show-access-token",
166
+ "--target-org",
167
+ self.sf_cli_org,
168
+ "--json",
169
+ ],
170
+ "sf org auth show-access-token",
171
+ )
172
+ access_token = token_data.get("result", {}).get("accessToken")
173
+ except RuntimeError:
174
+ # Command not available on older SF CLI versions
175
+ access_token = result_data.get("accessToken")
142
176
 
143
- if not access_token or not instance_url:
177
+ if not access_token:
144
178
  raise RuntimeError(
145
- f"'sf org display' did not return an access token or instance URL "
146
- f"for org '{self.sf_cli_org}'"
179
+ f"Could not obtain an access token for org '{self.sf_cli_org}'. "
180
+ f"Upgrade SF CLI to 2.136.6+ or ensure the org is authenticated."
147
181
  )
148
182
 
149
183
  return AccessTokenResponse(access_token=access_token, instance_url=instance_url)