Skip to content
Draft
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
10 changes: 10 additions & 0 deletions cli/azd/extensions/azure.ai.content-understanding/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@
displayed as tables or labeled cards. Default output and saved results remain
unchanged.

### Bugs Fixed

- Show sanitized service failure reasons and nested error codes in terminal
diagnostics and per-input reports, including HTTP, polling, and inline failures.
Keep useful model names and provide actionable guidance for `ModelNotSupported`
instead of replacing the root cause with a generic error. Redact credentials
and sensitive diagnostic values, and explicitly mark missing or shortened details.
Show accurate success/failure/skip counts and only mention retained result files
when files were actually saved.

### Other Changes

- Make `azd ai cu version` easier to read with a descriptive title, labeled
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ following behavior with the extension's adapter and command tests.
- Mark malformed successful responses with `ErrInvalidResponse`.
- Preserve unsuccessful terminal status and safe service codes in `OperationError`,
wrapping `azcore.ResponseError` while leaving CLI error classification to the adapter.
- Retain sanitized nested operation diagnostic codes without service messages or targets.
- Retain sanitized service diagnostic messages and nested codes from both `details` and
`innererror`, without targets, credentials, or raw response bodies. Normalize inner errors into
diagnostic `Details`, bound depth/count, and explicitly mark truncation.
- Share diagnostic filtering with the command boundary so safe model identifiers
and root-cause explanations survive repeated sanitization.
- Bound and sanitize service and polling errors before creating
`azcore.ResponseError` values.
- Use a same-resource polling handler that retains terminal envelopes without
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,15 @@ authentication contract.
- Reads and polling use the configured retry policy and honor cancellation.
Write submissions are not automatically retried or replayed after an
authentication challenge.
- Service and operation failures preserve typed status and safe diagnostic codes
without exposing raw service messages.
- Service and operation failures preserve typed status, diagnostic codes, and
sanitized messages. They retain useful model identifiers while redacting
credentials, URLs, paths, quoted customer values, and labeled document data.
`ServiceError` and `OperationError` both wrap `azcore.ResponseError`.
Terminal operation diagnostics normalize
`innererror` into nested `Details` alongside `details`, with limits of eight
levels and 32 diagnostics. `DiagnosticsTruncated` marks omitted diagnostics.
The shared `RedactDiagnostic` helper also protects the extension's command
error boundary without modifying successful result content.

See [client integration requirements](https://github.com/Azure/azure-dev/blob/main/cli/azd/extensions/azure.ai.content-understanding/cu-rest-client/AZD_PROVENANCE.md) for the behavior to verify
when changing the client dependency.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,9 @@ func (c *Client) send(req *policy.Request, codes ...int) (*http.Response, error)
if err != nil {
return nil, err
}
if resp.Request == nil {
resp.Request = req.Raw()
}
if !runtime.HasStatusCode(resp, codes...) {
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil, ErrInvalidResponse
Expand All @@ -218,6 +221,7 @@ func (c *Client) send(req *policy.Request, codes ...int) (*http.Response, error)

func safeResponseError(resp *http.Response, explicitCode string) error {
code := explicitCode
var diagnostic *OperationDiagnostic
if code == "" {
code = resp.Header.Get("x-ms-error-code")
}
Expand All @@ -231,17 +235,31 @@ func safeResponseError(resp *http.Response, explicitCode string) error {
if bodyCode, nested := deepestErrorCode(payload); bodyCode != "" && (code == "" || nested) {
code = bodyCode
}
var envelope struct {
Error *OperationDiagnostic `json:"error"`
}
if json.Unmarshal(payload, &envelope) == nil && envelope.Error != nil {
diagnostic = &safeOperationDiagnostics([]OperationDiagnostic{*envelope.Error}, resp)[0]
}
} else {
diagnostic = &OperationDiagnostic{
Message: "Service diagnostic omitted because the response exceeds the safe size limit.",
}
}
}
if !serviceErrorCodePattern.MatchString(code) {
if !serviceErrorCodePattern.MatchString(code) || redactResponseDiagnostic(code, resp) != code {
code = fmt.Sprintf("http_%d", resp.StatusCode)
}
safe := &http.Response{
Status: resp.Status, StatusCode: resp.StatusCode, Proto: resp.Proto,
ProtoMajor: resp.ProtoMajor, ProtoMinor: resp.ProtoMinor,
Header: resp.Header.Clone(), Body: http.NoBody, ContentLength: 0,
}
return &azcore.ResponseError{ErrorCode: code, StatusCode: resp.StatusCode, RawResponse: safe}
responseError := &azcore.ResponseError{ErrorCode: code, StatusCode: resp.StatusCode, RawResponse: safe}
if diagnostic != nil {
return &ServiceError{Diagnostic: *diagnostic, ResponseError: responseError}
}
return responseError
}

func deepestErrorCode(payload []byte) (string, bool) {
Expand All @@ -255,7 +273,7 @@ func deepestErrorCode(payload []byte) (string, bool) {
code := ""
nested := false
depth := 0
for len(current) != 0 {
for len(current) != 0 && depth < 8 {
var item struct {
Code string `json:"code"`
InnerError json.RawMessage `json:"innererror"`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package curestclient

import (
"net/http"
"regexp"
"strings"
"unicode"
"unicode/utf8"
)

const MaxDiagnosticBytes = 2048

var (
diagnosticAuthorization = regexp.MustCompile(`(?im)\b(?:proxy-)?authorization\s*[:=]\s*[^\r\n]*`)
diagnosticSecret = regexp.MustCompile(
`(?im)\b(?:[a-z_]*token|api[-_]?key|ocp-apim-subscription-key|account[-_]?key|client_secret|` +
`password|secret|sas|sig|schema|content|text|payload|body|path)\s*[:=]\s*[^\r\n]*`)
diagnosticURL = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://[^\s"'<>]+`)
diagnosticWindowsPath = regexp.MustCompile(`(?i)(?:[a-z]:[\\/]|\\\\)[^\s"'<>]+`)
diagnosticPOSIXPath = regexp.MustCompile(`(^|[\s"'=(])(?:/|~/|\.\.?/)[^\s"'<>]+`)
diagnosticQuotedValue = regexp.MustCompile(`(?m)"[^"\r\n]*(?:"|$)|(^|[\s:=(])'[^'\r\n]*(?:'|$)`)
diagnosticFieldPath = regexp.MustCompile(`\b(fieldSchema\.fields|fields|properties)\.[^\s:]+`)
diagnosticModelName = regexp.MustCompile(
`(?i)\b(model(?: name)?\s+)(?:'([a-z0-9][a-z0-9._-]{0,127})'|"([a-z0-9][a-z0-9._-]{0,127})")`)
)

// RedactDiagnostic preserves diagnostic prose and model identifiers while removing
// credentials, URLs, paths, quoted customer values, and labeled document data.
// It must not be used to alter successful service results.
func RedactDiagnostic(message string) string {
if len(message) > 64*1024 || !utf8.ValidString(message) {
return "Diagnostic omitted because it exceeds the safe text limit."
}

if start := strings.IndexByte(message, '{'); start >= 0 {
message = message[:start] + "[redacted payload]"
}
message = diagnosticAuthorization.ReplaceAllString(message, "Authorization: [redacted]")
message = diagnosticSecret.ReplaceAllString(message, "[redacted value]")
message = diagnosticURL.ReplaceAllString(message, "[redacted URL]")
message = diagnosticWindowsPath.ReplaceAllString(message, "[redacted path]")
message = diagnosticPOSIXPath.ReplaceAllString(message, "${1}[redacted path]")
// Remove quotes only around explicitly identified, syntactically safe model names.
message = diagnosticModelName.ReplaceAllString(message, "${1}${2}${3}")
message = diagnosticQuotedValue.ReplaceAllStringFunc(message, func(value string) string {
if value[0] == '"' {
return "[redacted value]"
}
return value[:strings.IndexByte(value, '\'')] + "[redacted value]"
})
message = diagnosticFieldPath.ReplaceAllString(message, "${1}.[redacted]")
message = strings.Map(func(r rune) rune {
if unicode.IsControl(r) && r != '\n' && r != '\t' || unicode.Is(unicode.Bidi_Control, r) {
return -1
}
return r
}, message)
message = strings.TrimSpace(message)
if len(message) > MaxDiagnosticBytes {
return "Diagnostic omitted because it exceeds the safe text limit."
}
return message
}

func redactResponseDiagnostic(message string, response *http.Response) string {
if response != nil && response.Request != nil {
for _, name := range []string{"Authorization", "Proxy-Authorization", "Ocp-Apim-Subscription-Key"} {
value := response.Request.Header.Get(name)
if value == "" {
continue
}
message = strings.ReplaceAll(message, value, "[redacted credential]")
if name != "Ocp-Apim-Subscription-Key" {
_, credential, found := strings.Cut(value, " ")
if found && strings.TrimSpace(credential) != "" {
message = strings.ReplaceAll(message, strings.TrimSpace(credential), "[redacted credential]")
}
}
}
}
return RedactDiagnostic(message)
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,28 @@ var ErrInvalidResponse = errors.New("invalid Content Understanding response")
type OperationError struct {
Status string
Code string
Message string
Details []OperationDiagnostic
ResponseError *azcore.ResponseError
}

// OperationDiagnostic retains nested service codes without customer messages or targets.
// ServiceError retains sanitized HTTP failure diagnostics and wraps azcore.ResponseError.
type ServiceError struct {
Diagnostic OperationDiagnostic
ResponseError *azcore.ResponseError
}

func (e *ServiceError) Error() string { return e.ResponseError.Error() }

func (e *ServiceError) Unwrap() error { return e.ResponseError }

// OperationDiagnostic retains bounded service codes and sanitized messages, never targets.
// Returned diagnostics normalize InnerError into Details.
type OperationDiagnostic struct {
Code string `json:"code"`
Details []OperationDiagnostic `json:"details,omitempty"`
Code string `json:"code"`
Message string `json:"message,omitempty"`
Details []OperationDiagnostic `json:"details,omitempty"`
InnerError *OperationDiagnostic `json:"innererror,omitempty"`
}

func (e *OperationError) Error() string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,8 @@ type operationPoller[T any] struct {
location string
done bool
failed bool
code string
status string
details []OperationDiagnostic
problem OperationDiagnostic
operationID string
response *http.Response
payload []byte
Expand Down Expand Up @@ -84,7 +83,7 @@ func (p *operationPoller[T]) Result(_ context.Context, out *T) error {
return errors.New("operation has not completed")
}
if p.failed {
return newOperationError(p.response, p.status, OperationDiagnostic{Code: p.code, Details: p.details})
return newOperationError(p.response, p.status, p.problem)
}
if err := json.Unmarshal(p.payload, out); err != nil {
return errors.Join(ErrInvalidResponse, err)
Expand Down Expand Up @@ -120,9 +119,8 @@ func (p *operationPoller[T]) accept(resp *http.Response, polled bool) error {
case "failed", "canceled", "cancelled":
p.done = true
p.failed = true
p.code = state.Error.Code
p.status = strings.ToLower(state.Status)
p.details = state.Error.Details
p.problem = state.Error
case "notstarted", "running":
default:
if state.AnalyzerID != "" && (p.location == "" || strings.EqualFold(state.Status, "ready")) {
Expand All @@ -139,7 +137,7 @@ func (p *operationPoller[T]) accept(resp *http.Response, polled bool) error {

func newOperationError(resp *http.Response, status string, problem OperationDiagnostic) error {
code := problem.Code
if !serviceErrorCodePattern.MatchString(code) {
if !serviceErrorCodePattern.MatchString(code) || redactResponseDiagnostic(code, resp) != code {
code = ""
}
responseCode := code
Expand All @@ -151,24 +149,39 @@ func newOperationError(resp *http.Response, status string, problem OperationDiag
if !errors.As(err, &responseError) {
return err
}
diagnostic := safeOperationDiagnostics([]OperationDiagnostic{problem}, resp)[0]
return &OperationError{
Status: status, Code: code, Details: safeOperationDiagnostics(problem.Details), ResponseError: responseError,
Status: status, Code: code,
Message: diagnostic.Message, Details: diagnostic.Details, ResponseError: responseError,
}
}

func safeOperationDiagnostics(values []OperationDiagnostic) []OperationDiagnostic {
if values == nil {
return nil
}
result := make([]OperationDiagnostic, len(values))
for i, value := range values {
code := value.Code
if !serviceErrorCodePattern.MatchString(code) {
code = "service_error"
func safeOperationDiagnostics(values []OperationDiagnostic, response *http.Response) []OperationDiagnostic {
remaining := 32
var visit func([]OperationDiagnostic, int) []OperationDiagnostic
visit = func(values []OperationDiagnostic, depth int) []OperationDiagnostic {
var result []OperationDiagnostic
for _, value := range values {
if remaining == 0 || depth == 8 {
result = append(result, OperationDiagnostic{Code: "DiagnosticsTruncated"})
break
}
remaining--
code := value.Code
if !serviceErrorCodePattern.MatchString(code) || redactResponseDiagnostic(code, response) != code {
code = "service_error"
}
children := value.Details
if value.InnerError != nil {
children = append([]OperationDiagnostic{*value.InnerError}, children...)
}
result = append(result, OperationDiagnostic{
Code: code, Message: redactResponseDiagnostic(value.Message, response), Details: visit(children, depth+1),
})
}
result[i] = OperationDiagnostic{Code: code, Details: safeOperationDiagnostics(value.Details)}
return result
}
return result
return visit(values, 0)
}

func (c *Client) operationLocation(value, baseURL string) (string, error) {
Expand Down
Loading
Loading