Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 13 additions & 14 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -822,23 +822,22 @@ func isDeployed(
*errs = append(*errs, fmt.Errorf("read %s: %w", key, err))
return false
}
if value != "" {
return true
}

// Voice agents (kind: prompt-voice) deploy without an agent-version object,
// so they never set AGENT_<KEY>_VERSION. Fall back to the base endpoint
// marker, which every voice deploy writes, so a successfully created voice
// agent is not reported as undeployed. Gate this on the service's actual
// declared kind: a hosted agent whose deploy partially failed can also
// present an empty VERSION with a lingering ENDPOINT, and must stay reported
// as not-deployed. This mirrors the kind gate in
if !isVoice {
return value != ""
}

// Voice deploys use the base ENDPOINT env var as the completion marker. The
// legacy voice API does not produce AGENT_<KEY>_VERSION, and unified voice
// deploys write VERSION before ENDPOINT to keep ENDPOINT as the final marker.
// Require ENDPOINT for voice even when VERSION is present, otherwise a partial
// env write could be reported as deployed before the callable endpoint was
// persisted. Gate this on the service's actual declared kind: a hosted agent
// whose deploy partially failed can also present an empty VERSION with a
// lingering ENDPOINT, and must stay reported as not-deployed. This mirrors the
// kind gate in
// AgentServiceTargetProvider.Endpoints (project package); the two live in
// separate packages because project imports nextstep, so a literally shared
// helper would create an import cycle.
if !isVoice {
return false
}
endpointKey := fmt.Sprintf(agentEndpointVarFormat, serviceKey(serviceName))
endpointValue, err := src.EnvValue(ctx, envName, endpointKey)
if err != nil {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -739,10 +739,10 @@ func TestServiceKey(t *testing.T) {
}
}

// TestIsDeployed_VoiceEndpointFallback verifies that a voice agent — which sets
// only AGENT_<KEY>_NAME and AGENT_<KEY>_ENDPOINT, never AGENT_<KEY>_VERSION — is
// still reported as deployed via the base endpoint marker, while an agent with
// neither version nor endpoint is reported undeployed.
// TestIsDeployed_VoiceEndpointFallback verifies that voice readiness is based
// on the base endpoint marker. Legacy voice agents never set VERSION, while
// unified voice agents set VERSION before ENDPOINT; in both cases ENDPOINT is
// the deploy completion marker.
func TestIsDeployed_VoiceEndpointFallback(t *testing.T) {
t.Parallel()

Expand All @@ -757,6 +757,21 @@ func TestIsDeployed_VoiceEndpointFallback(t *testing.T) {
values: map[string]string{"env1/AGENT_VOICE_SVC_VERSION": "1"},
want: true,
},
{
name: "version set but endpoint missing: undeployed (voice agent partial write)",
values: map[string]string{"env1/AGENT_VOICE_SVC_VERSION": "1"},
isVoice: true,
want: false,
},
{
name: "version and endpoint set: deployed (unified voice agent)",
values: map[string]string{
"env1/AGENT_VOICE_SVC_VERSION": "1",
"env1/AGENT_VOICE_SVC_ENDPOINT": "wss://x/agents/a/endpoint/protocols/voice?api-version=v1",
},
isVoice: true,
want: true,
},
{
name: "no version but base endpoint set: deployed (voice agent)",
values: map[string]string{"env1/AGENT_VOICE_SVC_ENDPOINT": "https://x/voice_agents/a"},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -398,12 +398,28 @@ type VoiceOutputConfig struct {
Voice *VoiceConfig `json:"voice,omitempty"`
}

// VoiceOutputConfigFlat is the newer Voice Live output shape used by the
// unified /agents voice API in TiP. Older regions still accept/return the
// object-shaped VoiceOutputConfig above.
type VoiceOutputConfigFlat struct {
Format *VoiceAudioFormat `json:"format,omitempty"`
Voice string `json:"voice,omitempty"`
VoiceType string `json:"voice_type,omitempty"`
VoiceLocale string `json:"voice_locale,omitempty"`
}

// VoiceAudioConfig bundles the input and output audio configuration.
type VoiceAudioConfig struct {
Input *VoiceInputConfig `json:"input,omitempty"`
Output *VoiceOutputConfig `json:"output,omitempty"`
}

// VoiceAudioConfigFlat bundles voice audio config with the flat output shape.
type VoiceAudioConfigFlat struct {
Input *VoiceInputConfig `json:"input,omitempty"`
Output *VoiceOutputConfigFlat `json:"output,omitempty"`
}

// VoiceAgentDefinition is the data-plane definition body POSTed to the
// /voice_agents collection for a declarative (managed) voice agent. Its Kind
// is always AgentKindVoice ("voice").
Expand All @@ -417,6 +433,19 @@ type VoiceAgentDefinition struct {
Store *bool `json:"store,omitempty"`
}

// VoiceAgentDefinitionFlat is the voice definition shape aligned with the
// unified /agents TiP API, where audio.output.voice is a string and the voice
// provider details are sibling fields.
type VoiceAgentDefinitionFlat struct {
AgentDefinition
ModelType VoiceModelType `json:"model_type"`
Model string `json:"model"`
Instructions string `json:"instructions,omitempty"`
Audio *VoiceAudioConfigFlat `json:"audio,omitempty"`
OutputModalities []string `json:"output_modalities,omitempty"`
Store *bool `json:"store,omitempty"`
}

// CreateAgentVersionRequest represents a request to create an agent version
type CreateAgentVersionRequest struct {
Description *string `json:"description,omitempty"`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,55 @@ func (c *AgentClient) CreateAgent(ctx context.Context, request *CreateAgentReque
// header while voice agents remain a preview capability.
const voiceAgentsPreviewFeature = "VoiceAgents=V1Preview"

func (c *AgentClient) doVoiceJSONAgentRequest(
ctx context.Context,
method string,
url string,
request any,
overriddenHost string,
) (*AgentObject, error) {
payload, err := json.Marshal(request)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}

req, err := runtime.NewRequest(ctx, method, url)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}

req.Raw().Header.Set("Foundry-Features", voiceAgentsPreviewFeature)
if overriddenHost != "" {
req.Raw().Header.Set("x-ms-overridden-host", overriddenHost)
}

if err := req.SetBody(streaming.NopCloser(bytes.NewReader(payload)), "application/json"); err != nil {
return nil, fmt.Errorf("failed to set request body: %w", err)
}

resp, err := c.pipeline.Do(req)
if err != nil {
return nil, fmt.Errorf("HTTP request failed: %w", err)
}
defer resp.Body.Close()

if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) {
return nil, runtime.NewResponseError(resp)
}

body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}

var agent AgentObject
if err := json.Unmarshal(body, &agent); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}

return &agent, nil
}

// CreateVoiceAgent creates a new declarative (managed) voice agent.
//
// Voice agents live in a separate data-plane collection (/voice_agents), distinct
Expand All @@ -193,36 +242,36 @@ func (c *AgentClient) CreateVoiceAgent(
overriddenHost string,
) (*AgentObject, error) {
url := fmt.Sprintf("%s/voice_agents?api-version=%s", c.endpoint, apiVersion)
return c.doVoiceJSONAgentRequest(ctx, http.MethodPost, url, request, overriddenHost)
}

payload, err := json.Marshal(request)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}

req, err := runtime.NewRequest(ctx, http.MethodPost, url)
// GetVoiceAgentUnified retrieves a voice agent through the unified /agents
// endpoint with the voice preview opt-in header. Use this instead of GetAgent
// when deciding whether to create or update a prompt voice agent.
func (c *AgentClient) GetVoiceAgentUnified(
ctx context.Context,
agentName string,
apiVersion string,
overriddenHost string,
) (*AgentObject, error) {
url := fmt.Sprintf("%s/agents/%s?api-version=%s", c.endpoint, agentName, apiVersion)
req, err := runtime.NewRequest(ctx, http.MethodGet, url)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}

// Voice agents are a preview feature; the service rejects the request with
// 403 preview_feature_required unless this opt-in header is present.
req.Raw().Header.Set("Foundry-Features", voiceAgentsPreviewFeature)

if overriddenHost != "" {
req.Raw().Header.Set("x-ms-overridden-host", overriddenHost)
}

if err := req.SetBody(streaming.NopCloser(bytes.NewReader(payload)), "application/json"); err != nil {
return nil, fmt.Errorf("failed to set request body: %w", err)
}

resp, err := c.pipeline.Do(req)
if err != nil {
return nil, fmt.Errorf("HTTP request failed: %w", err)
}
defer resp.Body.Close()

if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) {
if !runtime.HasStatusCode(resp, http.StatusOK) {
return nil, runtime.NewResponseError(resp)
}

Expand All @@ -239,6 +288,31 @@ func (c *AgentClient) CreateVoiceAgent(
return &agent, nil
}

// CreateVoiceAgentUnified creates a voice agent through the unified /agents
// collection. This path is opt-in while regional rollout is still in progress.
func (c *AgentClient) CreateVoiceAgentUnified(
ctx context.Context,
request *CreateAgentRequest,
apiVersion string,
overriddenHost string,
) (*AgentObject, error) {
url := fmt.Sprintf("%s/agents?api-version=%s", c.endpoint, apiVersion)
return c.doVoiceJSONAgentRequest(ctx, http.MethodPost, url, request, overriddenHost)
}

// UpdateVoiceAgentUnified creates a new version for an existing voice agent
// through the unified /agents/{name} endpoint.
func (c *AgentClient) UpdateVoiceAgentUnified(
ctx context.Context,
agentName string,
request *UpdateAgentRequest,
apiVersion string,
overriddenHost string,
) (*AgentObject, error) {
url := fmt.Sprintf("%s/agents/%s?api-version=%s", c.endpoint, agentName, apiVersion)
return c.doVoiceJSONAgentRequest(ctx, http.MethodPost, url, request, overriddenHost)
}

// UpdateAgent updates an existing agent
func (c *AgentClient) UpdateAgent(ctx context.Context, agentName string, request *UpdateAgentRequest, apiVersion string) (*AgentObject, error) {
url := fmt.Sprintf("%s/agents/%s?api-version=%s", c.endpoint, agentName, apiVersion)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -948,6 +948,73 @@ func TestCreateVoiceAgent_PostsToVoiceCollectionWithPreviewHeader(t *testing.T)
require.Contains(t, string(reqBody), `"name":"my-voice"`)
}

func TestCreateVoiceAgentUnified_PostsToAgentsWithPreviewHeader(t *testing.T) {
body := `{"object":"agent","id":"va-1","name":"my-voice","versions":{"latest":{}}}`
client, transport := newCaptureClient(http.StatusOK, body)

agent, err := client.CreateVoiceAgentUnified(
t.Context(),
&CreateAgentRequest{Name: "my-voice"},
AgentEndpointAPIVersion,
"",
)

require.NoError(t, err)
require.Equal(t, "my-voice", agent.Name)
require.Len(t, transport.requests, 1)
req := transport.requests[0]
require.Equal(t, http.MethodPost, req.Method)
require.Equal(t, "/api/projects/proj/agents", req.URL.Path)
require.Equal(t, AgentEndpointAPIVersion, req.URL.Query().Get("api-version"))
require.Equal(t, voiceAgentsPreviewFeature, req.Header.Get("Foundry-Features"))
}

func TestGetVoiceAgentUnified_GetsNamedAgentWithPreviewHeader(t *testing.T) {
body := `{"object":"agent","id":"va-1","name":"my-voice","versions":{"latest":{"version":"3"}}}`
client, transport := newCaptureClient(http.StatusOK, body)

agent, err := client.GetVoiceAgentUnified(
t.Context(),
"my-voice",
AgentEndpointAPIVersion,
"regional.hyena.example.com",
)

require.NoError(t, err)
require.Equal(t, "my-voice", agent.Name)
require.Equal(t, "3", agent.Versions.Latest.Version)
require.Len(t, transport.requests, 1)
req := transport.requests[0]
require.Equal(t, http.MethodGet, req.Method)
require.Equal(t, "/api/projects/proj/agents/my-voice", req.URL.Path)
require.Equal(t, AgentEndpointAPIVersion, req.URL.Query().Get("api-version"))
require.Equal(t, voiceAgentsPreviewFeature, req.Header.Get("Foundry-Features"))
require.Equal(t, "regional.hyena.example.com", req.Header.Get("x-ms-overridden-host"))
}

func TestUpdateVoiceAgentUnified_PostsToNamedAgentWithPreviewHeader(t *testing.T) {
body := `{"object":"agent","id":"va-1","name":"my-voice","versions":{"latest":{"version":"2"}}}`
client, transport := newCaptureClient(http.StatusOK, body)

agent, err := client.UpdateVoiceAgentUnified(
t.Context(),
"my-voice",
&UpdateAgentRequest{},
AgentEndpointAPIVersion,
"regional.hyena.example.com",
)

require.NoError(t, err)
require.Equal(t, "my-voice", agent.Name)
require.Len(t, transport.requests, 1)
req := transport.requests[0]
require.Equal(t, http.MethodPost, req.Method)
require.Equal(t, "/api/projects/proj/agents/my-voice", req.URL.Path)
require.Equal(t, AgentEndpointAPIVersion, req.URL.Query().Get("api-version"))
require.Equal(t, voiceAgentsPreviewFeature, req.Header.Get("Foundry-Features"))
require.Equal(t, "regional.hyena.example.com", req.Header.Get("x-ms-overridden-host"))
}

func TestCreateVoiceAgent_SetsOverriddenHostHeader(t *testing.T) {
client, transport := newCaptureClient(http.StatusCreated, `{"name":"my-voice","versions":{"latest":{}}}`)

Expand Down
Loading
Loading