mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: record and expose terminal upstream interception errors (#26961)
Categorises the terminal error of a failed interception and persists it on the interception record, then surfaces it on the AI Gateway API. - Categorise into an enum (`bad_request`, `unauthorized`, `rate_limited`, `overloaded`, `server_error`, `unknown`), unwrapping the ResponseError envelope, the upstream Anthropic/OpenAI SDK errors, and key-pool exhaustion so blocking and streaming paths agree. - Thread the type and raw message through the recorder dRPC into the `aibridge_interceptions` row (optional proto fields; NULL on success). - Expose the error on the AI Gateway thread API from the root interception. *This PR was produced by opencode (agent) using the `anthropic/claude-opus-4-8` model, under human direction and review.*
This commit is contained in:
@@ -1281,6 +1281,17 @@ func buildAIBridgeThread(
|
||||
n := rootIntc.AgentFirewallSequenceNumber.Int32
|
||||
thread.AgentFirewallSequenceNumber = &n
|
||||
}
|
||||
// Surface the terminal upstream error from the root interception. The
|
||||
// message is only meaningful alongside a type, so it is nested to avoid
|
||||
// a half-populated error on the response.
|
||||
if rootIntc.ErrorType.Valid {
|
||||
errType := string(rootIntc.ErrorType.AIBridgeInterceptionErrorType)
|
||||
thread.ErrorType = &errType
|
||||
if rootIntc.ErrorMessage.Valid {
|
||||
errMsg := rootIntc.ErrorMessage.String
|
||||
thread.ErrorMessage = &errMsg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute thread time bounds from interceptions.
|
||||
|
||||
@@ -2022,6 +2022,8 @@ func AIBridgeInterception(t testing.TB, db database.Store, seed database.InsertA
|
||||
ID: interception.ID,
|
||||
EndedAt: *endedAt,
|
||||
CredentialHint: takeFirst(seed.CredentialHint, ""),
|
||||
ErrorType: database.NullAIBridgeInterceptionErrorType{},
|
||||
ErrorMessage: sql.NullString{},
|
||||
})
|
||||
require.NoError(t, err, "insert aibridge interception")
|
||||
}
|
||||
|
||||
@@ -11037,6 +11037,59 @@ func TestUpdateAIBridgeInterceptionEnded(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "sk-u...byok", updated.CredentialHint)
|
||||
})
|
||||
|
||||
t.Run("ErrorRecorded", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
user := dbgen.User(t, db, database.User{})
|
||||
intc, err := db.InsertAIBridgeInterception(ctx, database.InsertAIBridgeInterceptionParams{
|
||||
ID: uuid.New(),
|
||||
InitiatorID: user.ID,
|
||||
Metadata: json.RawMessage("{}"),
|
||||
CredentialKind: database.CredentialKindCentralized,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, intc.ErrorType.Valid)
|
||||
require.False(t, intc.ErrorMessage.Valid)
|
||||
|
||||
updated, err := db.UpdateAIBridgeInterceptionEnded(ctx, database.UpdateAIBridgeInterceptionEndedParams{
|
||||
ID: intc.ID,
|
||||
EndedAt: time.Now(),
|
||||
ErrorType: database.NullAIBridgeInterceptionErrorType{
|
||||
AIBridgeInterceptionErrorType: database.AibridgeInterceptionErrorTypeOverloaded,
|
||||
Valid: true,
|
||||
},
|
||||
ErrorMessage: sql.NullString{String: "upstream overloaded", Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, updated.ErrorType.Valid)
|
||||
require.Equal(t, database.AibridgeInterceptionErrorTypeOverloaded, updated.ErrorType.AIBridgeInterceptionErrorType)
|
||||
require.True(t, updated.ErrorMessage.Valid)
|
||||
require.Equal(t, "upstream overloaded", updated.ErrorMessage.String)
|
||||
})
|
||||
|
||||
t.Run("NoErrorLeavesColumnsNull", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
user := dbgen.User(t, db, database.User{})
|
||||
intc, err := db.InsertAIBridgeInterception(ctx, database.InsertAIBridgeInterceptionParams{
|
||||
ID: uuid.New(),
|
||||
InitiatorID: user.ID,
|
||||
Metadata: json.RawMessage("{}"),
|
||||
CredentialKind: database.CredentialKindCentralized,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
updated, err := db.UpdateAIBridgeInterceptionEnded(ctx, database.UpdateAIBridgeInterceptionEndedParams{
|
||||
ID: intc.ID,
|
||||
EndedAt: time.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, updated.ErrorType.Valid)
|
||||
require.False(t, updated.ErrorMessage.Valid)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAIBridgeInterceptionAgentFirewallColumns(t *testing.T) {
|
||||
|
||||
Generated
+18
-6
@@ -2402,21 +2402,33 @@ UPDATE aibridge_interceptions
|
||||
credential_hint = CASE
|
||||
WHEN credential_kind = 'centralized' THEN $2::text
|
||||
ELSE credential_hint
|
||||
END
|
||||
END,
|
||||
-- Terminal upstream error, only set when the interception failed.
|
||||
-- NULL leaves the columns empty for successful interceptions.
|
||||
error_type = $3::aibridge_interception_error_type,
|
||||
error_message = $4::text
|
||||
WHERE
|
||||
id = $3::uuid
|
||||
id = $5::uuid
|
||||
AND ended_at IS NULL
|
||||
RETURNING id, initiator_id, provider, model, started_at, metadata, ended_at, api_key_id, client, thread_parent_id, thread_root_id, client_session_id, session_id, provider_name, credential_kind, credential_hint, agent_firewall_session_id, agent_firewall_sequence_number, error_type, error_message
|
||||
`
|
||||
|
||||
type UpdateAIBridgeInterceptionEndedParams struct {
|
||||
EndedAt time.Time `db:"ended_at" json:"ended_at"`
|
||||
CredentialHint string `db:"credential_hint" json:"credential_hint"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
EndedAt time.Time `db:"ended_at" json:"ended_at"`
|
||||
CredentialHint string `db:"credential_hint" json:"credential_hint"`
|
||||
ErrorType NullAIBridgeInterceptionErrorType `db:"error_type" json:"error_type"`
|
||||
ErrorMessage sql.NullString `db:"error_message" json:"error_message"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) UpdateAIBridgeInterceptionEnded(ctx context.Context, arg UpdateAIBridgeInterceptionEndedParams) (AIBridgeInterception, error) {
|
||||
row := q.db.QueryRowContext(ctx, updateAIBridgeInterceptionEnded, arg.EndedAt, arg.CredentialHint, arg.ID)
|
||||
row := q.db.QueryRowContext(ctx, updateAIBridgeInterceptionEnded,
|
||||
arg.EndedAt,
|
||||
arg.CredentialHint,
|
||||
arg.ErrorType,
|
||||
arg.ErrorMessage,
|
||||
arg.ID,
|
||||
)
|
||||
var i AIBridgeInterception
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
|
||||
@@ -15,7 +15,11 @@ UPDATE aibridge_interceptions
|
||||
credential_hint = CASE
|
||||
WHEN credential_kind = 'centralized' THEN @credential_hint::text
|
||||
ELSE credential_hint
|
||||
END
|
||||
END,
|
||||
-- Terminal upstream error, only set when the interception failed.
|
||||
-- NULL leaves the columns empty for successful interceptions.
|
||||
error_type = sqlc.narg('error_type')::aibridge_interception_error_type,
|
||||
error_message = sqlc.narg('error_message')::text
|
||||
WHERE
|
||||
id = @id::uuid
|
||||
AND ended_at IS NULL
|
||||
|
||||
Reference in New Issue
Block a user