Skip to content

Commit 30cda27

Browse files
feat: Add v1 OpenAI endpoint support and remove legacy completions API (#2560)
* Add v1 OpenAI Endpoint support and remove legacy completions API * Fix FuzzingUnitTest * Add test to increase code coverage * Make v1 api assumption cleaner * Add OpenAICompletion deprecation * Remove deprecation warnings * Fix RAI test for OpenAIPrompt * Revert "Add OpenAICompletion deprecation" This reverts commit fa708e2. * Revert "Fix RAI test for OpenAIPrompt" This reverts commit 3ed6044. * Revert "Remove deprecation warnings" This reverts commit 9a40c5c. * Reapply "Remove deprecation warnings" This reverts commit 987484c. * Reapply "Fix RAI test for OpenAIPrompt" This reverts commit f06f1ad. * Reapply "Add OpenAICompletion deprecation" This reverts commit 10715cd.
1 parent 71e8e6d commit 30cda27

26 files changed

Lines changed: 636 additions & 482 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Copyright (C) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License. See LICENSE in project root for information.
3+
4+
import warnings
5+
6+
__all__ = ["OpenAICompletion"]
7+
8+
_OPENAI_COMPLETION_DEPRECATION_MESSAGE = (
9+
"OpenAICompletion has been removed because the legacy OpenAI Completions API "
10+
"is deprecated and retired. Use OpenAIResponses, OpenAIChatCompletion, or "
11+
"OpenAIPrompt with setApiType('chat_completions') or setApiType('responses') instead."
12+
)
13+
14+
15+
def warn_openai_completion_deprecated(stacklevel=2):
16+
warnings.warn(
17+
_OPENAI_COMPLETION_DEPRECATION_MESSAGE,
18+
FutureWarning,
19+
stacklevel=stacklevel,
20+
)
21+
22+
23+
warn_openai_completion_deprecated(stacklevel=2)
24+
25+
26+
class OpenAICompletion:
27+
def __init__(self, *args, **kwargs):
28+
warn_openai_completion_deprecated(stacklevel=2)
29+
raise RuntimeError(_OPENAI_COMPLETION_DEPRECATION_MESSAGE)

cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/aifoundry/AIFoundryChatCompletion.scala

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,8 @@ class AIFoundryChatCompletion(override val uid: String) extends OpenAIChatComple
5858
setUrl(s"https://$v.services.ai.azure.com/" + urlPath.stripPrefix("/"))
5959
}
6060

61-
override protected def prepareUrlRoot: Row => String = { row =>
62-
s"${getUrl}models/chat/completions"
61+
override protected def prepareUrlRoot: Row => String = { _ =>
62+
endpointUrl("models/chat/completions")
6363
}
6464

6565
}
66-

cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAI.scala

Lines changed: 58 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -14,33 +14,9 @@ import org.apache.spark.sql.Row
1414
import org.apache.spark.sql.types._
1515
import spray.json.DefaultJsonProtocol._
1616

17+
import java.util.Locale
1718
import scala.language.existentials
1819

19-
trait HasPromptInputs extends HasServiceParams {
20-
val prompt: ServiceParam[String] = new ServiceParam[String](
21-
this, "prompt", "The text to complete", isRequired = false)
22-
23-
def getPrompt: String = getScalarParam(prompt)
24-
25-
def setPrompt(v: String): this.type = setScalarParam(prompt, v)
26-
27-
def getPromptCol: String = getVectorParam(prompt)
28-
29-
def setPromptCol(v: String): this.type = setVectorParam(prompt, v)
30-
31-
val batchPrompt: ServiceParam[Seq[String]] = new ServiceParam[Seq[String]](
32-
this, "batchPrompt", "Sequence of prompts to complete", isRequired = false)
33-
34-
def getBatchPrompt: Seq[String] = getScalarParam(batchPrompt)
35-
36-
def setBatchPrompt(v: Seq[String]): this.type = setScalarParam(batchPrompt, v)
37-
38-
def getBatchPromptCol: String = getVectorParam(batchPrompt)
39-
40-
def setBatchPromptCol(v: String): this.type = setVectorParam(batchPrompt, v)
41-
42-
}
43-
4420
trait HasMessagesInput extends Params {
4521
val messagesCol: Param[String] = new Param[String](
4622
this, "messagesCol", "The column messages to generate chat completions for," +
@@ -54,6 +30,29 @@ trait HasMessagesInput extends Params {
5430
case object OpenAIDeploymentNameKey extends GlobalKey[Either[String, String]]
5531
case object OpenAIEmbeddingDeploymentNameKey extends GlobalKey[Either[String, String]]
5632

33+
private[openai] object OpenAIEndpointUtils {
34+
private def stripTrailingSlashes(value: String): String = value.replaceAll("/+$", "")
35+
36+
private def withoutQueryOrFragment(value: String): String = {
37+
val stopAt = Seq(value.indexOf("?"), value.indexOf("#")).filter(_ >= 0) match {
38+
case Seq() => value.length
39+
case indexes => indexes.min
40+
}
41+
value.take(stopAt)
42+
}
43+
44+
def appendPath(baseUrl: String, path: String): String = {
45+
val separator = if (baseUrl.endsWith("/")) "" else "/"
46+
baseUrl + separator + path.stripPrefix("/")
47+
}
48+
49+
def isV1BaseUrl(baseUrl: String): Boolean = {
50+
stripTrailingSlashes(withoutQueryOrFragment(baseUrl))
51+
.toLowerCase(Locale.ROOT)
52+
.endsWith("/v1")
53+
}
54+
}
55+
5756
trait HasOpenAISharedParams extends HasServiceParams with HasAPIVersion {
5857

5958
val deploymentName = new ServiceParam[String](
@@ -137,7 +136,7 @@ trait HasOpenAITextParams extends HasOpenAISharedParams {
137136
"The maximum number of completion tokens to generate. Has minimum of 0." +
138137
" Works with both reasoning and non-reasoning models." +
139138
" Sent as max_completion_tokens for chat completions," +
140-
" max_output_tokens for responses API, and max_tokens for legacy completions.",
139+
" and max_output_tokens for responses API.",
141140
isRequired = false) {
142141
override val payloadName: String = "max_completion_tokens"
143142
}
@@ -456,6 +455,39 @@ abstract class OpenAIServicesBase(override val uid: String) extends CognitiveSer
456455
with HasOpenAISharedParams with OpenAIFabricSetting {
457456
setDefault(timeout -> 360.0)
458457

458+
override def setUrl(value: String): this.type = set(url, value)
459+
460+
protected[openai] def isOpenAIV1BaseUrl: Boolean =
461+
get(url).orElse(getDefault(url)).exists(OpenAIEndpointUtils.isV1BaseUrl)
462+
463+
protected[openai] def endpointUrl(path: String): String = OpenAIEndpointUtils.appendPath(getUrl, path)
464+
465+
protected[openai] def withV1DeploymentModel(params: Map[String, Any], row: Row): Map[String, Any] = {
466+
if (isOpenAIV1BaseUrl && !params.contains("model")) {
467+
params.updated("model", getValue(row, deploymentName))
468+
} else {
469+
params
470+
}
471+
}
472+
473+
private def warnIfV1ApiVersionConfigured(): Unit = {
474+
if (isOpenAIV1BaseUrl && (get(apiVersion).nonEmpty || GlobalParams.getParam(apiVersion).nonEmpty)) {
475+
logWarning(
476+
"apiVersion is ignored when the OpenAI URL is a v1 base URL. " +
477+
"Remove apiVersion or use a non-v1 endpoint.")
478+
}
479+
}
480+
481+
override protected def getUrlParams: Array[ServiceParam[_]] = {
482+
val params = super.getUrlParams
483+
if (isOpenAIV1BaseUrl) {
484+
warnIfV1ApiVersionConfigured()
485+
params.filterNot(_.name == apiVersion.name)
486+
} else {
487+
params
488+
}
489+
}
490+
459491
private def usingDefaultOpenAIEndpoint(): Boolean = {
460492
getUrl == FabricClient.MLWorkloadEndpointML + "/cognitive/openai/"
461493
}

cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIChatCompletion.scala

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,11 @@ class OpenAIChatCompletion(override val uid: String) extends OpenAIServicesBase(
113113
}
114114

115115
override protected def prepareUrlRoot: Row => String = { row =>
116-
s"${getUrl}openai/deployments/${getValue(row, deploymentName)}/chat/completions"
116+
if (isOpenAIV1BaseUrl) {
117+
endpointUrl("chat/completions")
118+
} else {
119+
endpointUrl(s"openai/deployments/${getValue(row, deploymentName)}/chat/completions")
120+
}
117121
}
118122

119123
override private[ml] def getOptionalParams(r: Row): Map[String, Any] = {
@@ -125,7 +129,7 @@ class OpenAIChatCompletion(override val uid: String) extends OpenAIServicesBase(
125129
r =>
126130
lazy val optionalParams: Map[String, Any] = getOptionalParams(r)
127131
val messages = r.getAs[Seq[Row]](getMessagesCol)
128-
Some(getStringEntity(messages, optionalParams))
132+
Some(getStringEntity(messages, withV1DeploymentModel(optionalParams, r)))
129133
}
130134

131135
override val subscriptionKeyHeaderName: String = "api-key"

cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAICompletion.scala

Lines changed: 0 additions & 75 deletions
This file was deleted.

cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIDefaults.scala

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,7 @@ object OpenAIDefaults {
4747
}
4848

4949
def setURL(v: String): Unit = {
50-
val url = if (v.endsWith("/")) v else v + "/"
51-
GlobalParams.setGlobalParam(URLKey, url)
50+
GlobalParams.setGlobalParam(URLKey, v)
5251
}
5352

5453
def getURL: Option[String] = {

cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIEmbedding.scala

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,19 @@ class OpenAIEmbedding (override val uid: String) extends OpenAIServicesBase(uid)
6464
}
6565

6666
override protected def prepareUrlRoot: Row => String = { row =>
67+
val dep = getEmbeddingDeployment(row)
68+
if (isOpenAIV1BaseUrl) {
69+
endpointUrl("embeddings")
70+
} else {
71+
endpointUrl(s"openai/deployments/$dep/embeddings")
72+
}
73+
}
74+
75+
private[this] def getEmbeddingDeployment(row: Row): String = {
6776
val globalEmbeddingDeployment =
6877
GlobalParams.getGlobalParam(OpenAIEmbeddingDeploymentNameKey).flatMap(_.left.toOption)
6978

70-
val dep = globalEmbeddingDeployment.orElse {
79+
globalEmbeddingDeployment.orElse {
7180
// If embedding-specific deployment is not set, check instance param
7281
if (isSet(deploymentName)) {
7382
getValueOpt(row, deploymentName)
@@ -77,8 +86,6 @@ class OpenAIEmbedding (override val uid: String) extends OpenAIServicesBase(uid)
7786
}.getOrElse(throw new IllegalArgumentException(
7887
"No embedding deployment name provided. Set the 'deploymentName' param or call " +
7988
"OpenAIDefaults.setEmbeddingDeploymentName('<your-embedding-deployment>') to set a global default."))
80-
81-
s"${getUrl}openai/deployments/$dep/embeddings"
8289
}
8390

8491
private[this] def getStringEntity[A](text: A, optionalParams: Map[String, Any]): StringEntity = {
@@ -88,7 +95,10 @@ class OpenAIEmbedding (override val uid: String) extends OpenAIServicesBase(uid)
8895

8996
override protected def prepareEntity: Row => Option[AbstractHttpEntity] = {
9097
r =>
91-
lazy val optionalParams: Map[String, Any] = getOptionalParams(r)
98+
lazy val optionalParams: Map[String, Any] = {
99+
val params = getOptionalParams(r)
100+
if (isOpenAIV1BaseUrl) params.updated("model", getEmbeddingDeployment(r)) else params
101+
}
92102
getValueOpt(r, text)
93103
.map(text => getStringEntity(text, optionalParams))
94104
.orElse(throw new IllegalArgumentException(

cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPrompt.scala

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,8 @@ class OpenAIPrompt(override val uid: String) extends Transformer
241241
store -> Left(false)
242242
)
243243

244+
override def setUrl(value: String): this.type = set(url, value)
245+
244246
override def setCustomServiceName(v: String): this.type = {
245247
setUrl(s"https://$v.openai.azure.com/" + urlPath.stripPrefix("/"))
246248
}
@@ -284,8 +286,6 @@ class OpenAIPrompt(override val uid: String) extends Transformer
284286
df: DataFrame,
285287
messagesCol: Column
286288
): (DataFrame, String, OpenAIServicesBase with HasTextOutput) = {
287-
// All services are now HasMessagesInput (OpenAIChatCompletion, OpenAIResponses, AIFoundryChatCompletion)
288-
// Legacy OpenAICompletion did not support MessagesInput which is no longer used in this class.
289289
val messagesService = service.asInstanceOf[HasMessagesInput]
290290

291291
if (isSet(responseFormat)) {
@@ -639,7 +639,12 @@ class OpenAIPrompt(override val uid: String) extends Transformer
639639
host.exists(_.toLowerCase.endsWith("services.ai.azure.com"))
640640
}
641641

642-
private[openai] def hasAIFoundryModel: Boolean = this.isDefined(model) && isAIFoundryEndpoint
642+
private def isOpenAIV1Endpoint: Boolean = {
643+
get(url).orElse(getDefault(url)).exists(OpenAIEndpointUtils.isV1BaseUrl)
644+
}
645+
646+
private[openai] def hasAIFoundryModel: Boolean =
647+
this.isDefined(model) && isAIFoundryEndpoint && !isOpenAIV1Endpoint
643648

644649
//deployment name can be set by user, it doesn't have to match with model name
645650
private def getOpenAIChatService: OpenAIServicesBase with HasTextOutput = {
@@ -658,11 +663,10 @@ class OpenAIPrompt(override val uid: String) extends Transformer
658663
.filter(p => !localParamNames.contains(p.param.name) && completion.hasParam(p.param.name))
659664
.foreach(p => completion.set(completion.getParam(p.param.name), p.value))
660665

661-
completion match {
662-
case resp: OpenAIResponses
663-
if this.isDefined(model) && get(deploymentName).orElse(getDefault(deploymentName)).isEmpty =>
664-
resp.setDeploymentName(getModel)
665-
case _ =>
666+
if (this.isDefined(model) &&
667+
get(deploymentName).orElse(getDefault(deploymentName)).isEmpty &&
668+
(isOpenAIV1Endpoint || completion.isInstanceOf[OpenAIResponses])) {
669+
completion.setDeploymentName(getModel)
666670
}
667671

668672
completion

cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIResponses.scala

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,11 @@ class OpenAIResponses(override val uid: String) extends OpenAIServicesBase(uid)
142142
}
143143

144144
override protected def prepareUrlRoot: Row => String = { row =>
145-
s"${getUrl}openai/responses"
145+
if (isOpenAIV1BaseUrl) {
146+
endpointUrl("responses")
147+
} else {
148+
endpointUrl("openai/responses")
149+
}
146150
}
147151

148152
override protected[openai] def prepareEntity: Row => Option[AbstractHttpEntity] = {
@@ -164,6 +168,9 @@ class OpenAIResponses(override val uid: String) extends OpenAIServicesBase(uid)
164168
private def mergeModel(params: Map[String, Any], r: Row): Map[String, Any] = {
165169
getValueOpt(r, deploymentName) match {
166170
case Some(m) if m != null && m.nonEmpty => params.updated("model", m)
171+
case _ if isOpenAIV1BaseUrl && !params.contains("model") =>
172+
throw new IllegalArgumentException(
173+
"No deployment/model name provided for OpenAI v1 endpoint. Set the 'deploymentName' param.")
167174
case _ => params
168175
}
169176
}

0 commit comments

Comments
 (0)