Visitar URL original
feat: add unified AI prompting and native decision model support (#2811) · microsoft/SynapseML@33200c2 · GitHub
Skip to content

Commit 33200c2

Browse files
feat: add unified AI prompting and native decision model support (#2811)
* feat: add unified AI prompting and native decision model support Add AIPrompt on the existing OpenAIPrompt interface for explicit chat, Responses, embeddings and native decision-model APIs. Preserve request options, typed results, errors and raw responses through Scala and generated Python, copy, persistence and pipelines. Add the AIPrompt notebook and guide. Keep the contribution focused on AIPrompt and small native specializations. Separate independent legacy defects and optional Delta recovery into their own issues and PRs. Exercise supported notebook examples on packaged artifacts, but leave OpenAIDecisions examples commented because Azure OpenAI/Foundry does not support that API. - Native inference proposal: #2759 - Deferred recovery: #2800 - Deferred live Decisions qualification: #2760 - Global parameter prerequisite: #2806 - Attachment cancellation prerequisite: #2807 - Response cleanup prerequisite: #2808 - Assigned-session prerequisite: #2809 - Python stub prerequisite: #2810 - Existing tutorial: docs/Explore Algorithms/OpenAI/OpenAI.ipynb Reuse existing authentication and HTTP infrastructure rather than add a second inference transport. Keep public behavior in Scala and generate Python conveniences without changing legacy public JVM signatures or serialized parameter shapes. Explicit API selection and lossless JSON avoid silent option loss; bounded cancellation handles interrupted Spark work. Keep ordinary inference lazy and replayable, without Delta, an automatic tool loop, response-size configuration or an exactly-once claim. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ba39157-c8dd-4adb-90e0-ff963a1e6733 * style: format AIPrompt Python regression tests Summary - Apply the pinned Black formatter to the AIPrompt regression suite and its Fabric validation helper. Prompting Intent - Keep the focused AIPrompt pull request limited to required feature work and repairs to its own validation gates. Linked Sources - #2811 - https://github.com/microsoft/SynapseML/actions/runs/42014432464 Rationale - The Python style gate identified formatting in two new test files. - The changes preserve both files' Python ASTs, and the repository's tracked Python files pass Black 22.3.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ba39157-c8dd-4adb-90e0-ff963a1e6733 * fix: align AIPrompt coverage count and notebook formatting ## Summary Account for the additional AI test lane in Codecov's expected upload count and apply the pinned notebook formatter to the new AIPrompt tutorial. ## Prompting Intent Repair the deterministic checks introduced by the focused AIPrompt change without changing dependency pins, workflows, release tooling or unrelated notebooks, and preserve the already-executed tutorial behavior. ## Linked Sources - #2759 - #2811 ## Rationale The authorized AI matrix lane raises expected uploads from 54 to 55. Keep the existing coverage-count assertion unchanged and fix its configuration. Black 22.3.0 with Jupyter support changes formatting only: all 18 code-cell ASTs, metadata and markdown remain identical, outputs stay cleared and OpenAIDecisions remains commented. The full CI Black command and the targeted coverage-upload selection test pass locally. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ba39157-c8dd-4adb-90e0-ff963a1e6733 * fix: harden AIPrompt cancellation and compatibility boundaries Integrate reviewed session-persistence, codegen and attachment fixes; retain task kill reasons, transport cancellation, value-codec regressions and the 32 supported notebook examples. Freeze scope, finish running reviews, address blockers, publish and run current-head CI instead of starting additional full review matrices. - #2811 - #2818 - #2819 - #2820 Ensure the integrated public AIPrompt path preserves the same cancellation, persistence and generated-wrapper contracts as the prerequisite fixes. Keep unsupported Decisions examples commented out and existing limitations visible. Do not expand scope to unrelated codegen or connector behavior. Source-scoped qualification covers 511 cognitive and 189 core cases with zero skips, six notebook cases and 33 installed Python cases. Compile, style, codegen, packaging and exact installed artifact provenance are retained. The latest test-only changes passed targeted regressions without altering the qualified runtime artifacts. Existing reviews and dispositions are retained; no additional full matrix is claimed. Fresh CI is still required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ba39157-c8dd-4adb-90e0-ff963a1e6733 * docs: explain AIPrompt API contracts with a diagram ## Summary Add a shared API-routing diagram to the AIPrompt getting-started notebook and usage guide. Clarify supported parameter mappings, intentional legacy differences, native results and provider availability without changing code. ## Prompting Intent Explain whether AIPrompt supports all five API types and preserves the semantics and parameters of OpenAIPrompt and the native decision stages. Generate an understandable diagram and include it in the PR description and the getting-started notebook. ## Linked Sources - #2811 - #2759 - #2760 - docs/Explore Algorithms/OpenAI/Unified AI prompting.md - cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/ai/AIRequestAdapter.scala - cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/ai/AIPrompt.scala - https://github.com/user-attachments/assets/bfcb52f9-b019-455b-9e23-598a44fae229 ## Rationale Use one GitHub-hosted diagram in the PR, notebook and guide, following the tutorials' hosted-image convention. Combine a generated background with deterministic labels and connectors for precise technical content. Keep the existing runnable examples unchanged and explicitly distinguish Foundry SystemOne from the unsupported Azure OpenAI/Foundry Decisions path. Describe per-API support instead of claiming universal legacy parity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ba39157-c8dd-4adb-90e0-ff963a1e6733 * fix: route AIPrompt API versions without a version allowlist Summary: - Select the v1 Chat Completions and Responses route with apiVersion. - Forward other version strings in api-version, including for Decisions. - Default OpenAIDecisions to v1 while preserving complete URL overrides. - Cover scalar, per-row and persisted versions and document migration. Prompting Intent: Let callers use v1, preview, dated and future API versions through the existing parameter without library-maintained version restrictions. Linked Sources: - User request to select the v1 URI and forward all other version strings. - #2759 - #2811 Rationale: API version selection belongs to the provider. Reuse the existing ServiceParam and preserve public signatures, legacy stages and URL override behavior instead of introducing a separate routing parameter. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ba39157-c8dd-4adb-90e0-ff963a1e6733 * fix: default AIPrompt chat and responses to v1 Summary: - Default Chat Completions and Responses requests to the v1 route. - Preserve explicit and configured version overrides and embedding defaults. - Cover default routing through copy, save/load and clearing an override. - Update the usage guide and notebook migration notes. Prompting Intent: Keep v1 as the default API version for Chat Completions and Responses, alongside the existing OpenAIDecisions v1 default. Linked Sources: - User request: keep v1 as the default for Chat Completions and Responses. - #2811 - #2759 Rationale: Use the v1 route without requiring a redundant setter. Change only the generative fallback so configured scalar and per-row versions, complete URL overrides, embeddings and legacy OpenAI stages retain their behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ba39157-c8dd-4adb-90e0-ff963a1e6733 * fix: support Foundry Decisions inference Foundry exposes Decisions for supported gpt-6-luna deployments, but the new stages rejected Azure hosts before HTTP and selected bearer-key authentication for every Decisions endpoint. Route Azure resource roots through /openai/v1/decisions, retain api-key authentication for Azure keys, and preserve Entra, proxy, version and complete-URL behavior. Replace obsolete rejection tests with routing and authentication regressions. Add opt-in Chat and Responses live tests, enable the notebook's Foundry Decisions examples, and correct the guide and shared diagram. User intent: correct Foundry support and live-test all four inference APIs. Sources: #2759, #2760, https://learn.microsoft.com/en-us/azure/foundry/openai/how-to/decisions Validation: 215 Scala cases, 33 installed-package Python cases, six notebook tests and four real service tests passed with zero skips. The live tests exercised Chat, Responses, Decisions and SystemOne using six valid stage invocations on existing Sweden Central deployments. Compilation, Scala style, Python formatting, codegen and packaging passed. Loaded JARs and generated Python sources matched the validated build. No new deployments or dependency changes; no full notebook rerun or current-head remote CI pass is claimed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ba39157-c8dd-4adb-90e0-ff963a1e6733 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ba39157-c8dd-4adb-90e0-ff963a1e6733
1 parent 80191a6 commit 33200c2

95 files changed

Lines changed: 18887 additions & 377 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎codecov.yaml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ codecov:
33
require_ci_to_pass: no
44
# Wait for expected number of coverage uploads before sending notifications
55
# This prevents premature comments with incomplete data.
6-
# 54 = UnitTests 40 + PythonTests 7 + RTests 6 + WebsiteSamplesTests 1
6+
# 55 = UnitTests 41 + PythonTests 7 + RTests 6 + WebsiteSamplesTests 1
77
# (every leg of those jobs runs templates/codecov.yml on succeededOrFailed).
8-
after_n_builds: 54
8+
after_n_builds: 55
99

1010
comment:
1111
layout: "reach,diff,flags,files"
@@ -14,7 +14,7 @@ comment:
1414
require_base: false
1515
require_head: true
1616
# Wait for all expected uploads before commenting (see note above)
17-
after_n_builds: 54
17+
after_n_builds: 55
1818

1919
coverage:
2020
precision: 2

‎cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/CognitiveServiceBase.scala‎

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,9 @@ object URLEncodingUtils {
329329
}
330330

331331
private[ml] object ServiceAuthHeaders {
332-
private[ml] case class Resolution(headers: Map[String, String], usesFabricFallback: Boolean)
332+
private[ml] case class Resolution(headers: Map[String, String],
333+
usesFabricFallback: Boolean,
334+
usesSubscriptionKey: Boolean)
333335

334336
private[ml] def nonBlank(value: String): Boolean = value != null && value.trim.nonEmpty
335337

@@ -377,20 +379,15 @@ private[ml] object ServiceAuthHeaders {
377379
def isAuthHeaderName(name: String): Boolean =
378380
name.equalsIgnoreCase(subscriptionKeyHeaderName) || name.equalsIgnoreCase(aadHeaderName)
379381

380-
// Resolve exactly one auth header across every credential source in a single, case-insensitive
381-
// precedence step. Blank values are skipped so they cannot suppress a valid lower-priority
382-
// credential, and the automatic Fabric fallback ranks below a credential embedded in
383-
// customHeaders (matching how management-index requests, which have no fallback, resolve auth).
384-
// fabricFallbackAuthHeader is by-name and lowest priority, so it is evaluated only when every
385-
// higher-priority source above is absent. A fallback that acquires a token (and may throw) is
386-
// therefore never run while a subscription key, AAD token, explicit custom-auth header, or an
387-
// embedded customHeaders credential is present.
388-
val explicitAuthHeader: Option[(String, String)] = subscriptionKey.filter(nonBlank)
389-
.map(value => subscriptionKeyHeaderName -> value)
390-
.orElse(aadToken.filter(nonBlank).map(value => aadHeaderName -> ("Bearer " + value)))
391-
.orElse(customAuthHeader.filter(nonBlank).map(value => aadHeaderName -> value))
392-
.orElse(embeddedCredential(providedCustomHeaders, subscriptionKeyHeaderName))
393-
.orElse(embeddedCredential(providedCustomHeaders, aadHeaderName))
382+
// Select one non-blank credential and retain its source even when the auth header names coincide.
383+
// The by-name Fabric fallback is evaluated only when no explicit or embedded credential exists.
384+
val explicitAuth: Option[((String, String), Boolean)] = subscriptionKey.filter(nonBlank)
385+
.map(value => (subscriptionKeyHeaderName -> value) -> true)
386+
.orElse(aadToken.filter(nonBlank).map(value => (aadHeaderName -> ("Bearer " + value)) -> false))
387+
.orElse(customAuthHeader.filter(nonBlank).map(value => (aadHeaderName -> value) -> false))
388+
.orElse(embeddedCredential(providedCustomHeaders, subscriptionKeyHeaderName).map(_ -> true))
389+
.orElse(embeddedCredential(providedCustomHeaders, aadHeaderName).map(_ -> false))
390+
val explicitAuthHeader = explicitAuth.map(_._1)
394391
val fabricAuthHeader = if (explicitAuthHeader.isDefined) {
395392
None
396393
} else {
@@ -421,7 +418,8 @@ private[ml] object ServiceAuthHeaders {
421418

422419
Resolution(
423420
new scala.collection.immutable.TreeMap[String, String]() ++ headers,
424-
usesFabricFallback = fabricAuthHeader.isDefined)
421+
usesFabricFallback = fabricAuthHeader.isDefined,
422+
usesSubscriptionKey = explicitAuth.exists(_._2))
425423
}
426424

427425
def build(subscriptionKey: Option[String],
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright (C) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License. See LICENSE in project root for information.
3+
4+
package com.microsoft.azure.synapse.ml.services.ai
5+
6+
import org.apache.spark.sql.{Column, Row, functions => F}
7+
import org.apache.spark.sql.catalyst.expressions.GenericRowWithSchema
8+
import org.apache.spark.sql.internal.SQLConf
9+
import org.apache.spark.sql.types.{ArrayType, DataType, MapType, StructField, StructType}
10+
11+
import java.util.{HashSet, Set => JavaSet, TreeSet}
12+
13+
private[ai] final case class AIColumnPlan(input: StructType, outputs: Seq[StructField]) {
14+
AIColumnPlan.distinct(input.fieldNames.toSeq, "input columns")
15+
AIColumnPlan.distinct(outputs.map(_.name), "output roles")
16+
require(outputs.forall(_.name.nonEmpty), "output roles must have non-empty names")
17+
private val outputNames = outputs.map(_.name).toSet
18+
private val replacements = input.fields.map(f =>
19+
outputs.find(o => AIColumnPlan.matches(f.name, o.name)))
20+
val schema: StructType = StructType(input.fields.zip(replacements).map {
21+
case (original, replacement) => replacement.getOrElse(original)
22+
} ++ outputs.filterNot(o => input.fields.exists(f => AIColumnPlan.matches(f.name, o.name))))
23+
24+
private val indices = schema.fields.map(f =>
25+
if (outputNames(f.name)) -1 else input.fieldIndex(f.name))
26+
27+
def assemble(row: Row, values: Map[String, Any]): Row =
28+
Row.fromSeq(schema.fields.zip(indices).map { case (field, index) =>
29+
if (index < 0) values.getOrElse(field.name, None.orNull) else row.get(index)
30+
})
31+
32+
def scratch(prefix: String, additional: String*): String =
33+
AIColumnPlan.unused(prefix, schema.fieldNames.toSeq ++ input.fieldNames ++ additional)
34+
}
35+
36+
private[ai] object AIColumnPlan {
37+
def matches(left: String, right: String): Boolean = SQLConf.get.resolver(left, right)
38+
39+
def distinct(names: Seq[String], description: String): Unit = {
40+
require(names.forall(_ != null), s"$description must not have null names")
41+
// This comparator matches Spark's equalsIgnoreCase, including Unicode variants lost by lowercasing.
42+
val seen: JavaSet[String] = if (SQLConf.get.caseSensitiveAnalysis) new HashSet[String]()
43+
else new TreeSet[String](String.CASE_INSENSITIVE_ORDER)
44+
require(!names.exists(name => !seen.add(name)),
45+
s"Ambiguous $description under Spark's column resolver")
46+
}
47+
48+
def resolve(schema: StructType, name: String): String =
49+
schema.fieldNames.find(matches(_, name)).getOrElse(
50+
throw new IllegalArgumentException(s"Input column '$name' does not exist"))
51+
52+
def unused(prefix: String, names: Seq[String]): String =
53+
(Iterator(prefix) ++ Iterator.from(1).map(i => s"${prefix}_$i"))
54+
.find(candidate => !names.exists(matches(_, candidate))).get
55+
56+
def column(name: String): Column = F.col("`" + name.replace("`", "``") + "`")
57+
58+
// Spark can eliminate serialization between Dataset maps and retain external JVM arrays.
59+
def arrayValues(value: Any): scala.collection.Seq[Any] = value match {
60+
case values: scala.collection.Seq[_] => values
61+
case values: Array[_] => values.toSeq
62+
case _ => throw new IllegalArgumentException("Expected a Spark array value")
63+
}
64+
65+
// Fused Dataset maps can pass schema-less Rows directly to the protocol serializers.
66+
def withSchema(row: Row, schema: StructType): Row =
67+
new GenericRowWithSchema(schema.fields.zipWithIndex.map { case (field, index) =>
68+
nestedSchema(row.get(index), field.dataType)
69+
}, schema)
70+
71+
private def nestedSchema(value: Any, dataType: DataType): Any = Option(value).map { present =>
72+
dataType match {
73+
case struct: StructType => withSchema(present.asInstanceOf[Row], struct)
74+
case ArrayType(element, _) =>
75+
arrayValues(present).iterator.map(nestedSchema(_, element)).toVector
76+
case MapType(key, element, _) =>
77+
present.asInstanceOf[scala.collection.Map[Any, Any]].iterator.map { case (k, v) =>
78+
nestedSchema(k, key) -> nestedSchema(v, element)
79+
}.toMap
80+
case _ => present
81+
}
82+
}.orNull
83+
}

0 commit comments

Comments
 (0)