mirror of
https://github.com/hanzoai/agent.git
synced 2026-09-11 21:08:51 +00:00
round: pass a completion refusal (402 insufficient_balance, 429, 403) through verbatim
The round masked every completion failure as a 502 "agent: completion: ...", so a
caller with no credits saw an opaque gateway error instead of the real billing
message. Add UpstreamError{Status,Body}: a Completer returns it for a caller-facing
4xx, and the round passes the status + body through unchanged (a client shows "add
credits"). A non-4xx failure (5xx/transport) stays a 502 — that IS a gateway fault.
Tests: 402 verbatim passthrough + 5xx-stays-502.
This commit is contained in:
@@ -40,6 +40,22 @@ type Completer interface {
|
||||
Complete(ctx context.Context, cred map[string]string, req openai.ChatCompletionRequest) (openai.ChatCompletionResponse, error)
|
||||
}
|
||||
|
||||
// UpstreamError is returned by a Completer when the completion service refuses the
|
||||
// request for the CALLER's OWN reason — a 4xx like 402 insufficient_balance, 429
|
||||
// rate-limit, or 403 — that must reach the caller intact, not be masked as a
|
||||
// gateway 502. Body is the completion's verbatim error payload; the round passes
|
||||
// Status + Body straight through so a client shows the real message ("add credits")
|
||||
// instead of an opaque "agent: completion" wrapper. A non-4xx completion failure
|
||||
// (5xx, transport) stays a 502 — that IS a gateway fault.
|
||||
type UpstreamError struct {
|
||||
Status int
|
||||
Body []byte
|
||||
}
|
||||
|
||||
func (e *UpstreamError) Error() string {
|
||||
return fmt.Sprintf("upstream completion %d: %s", e.Status, e.Body)
|
||||
}
|
||||
|
||||
// Tool is the agent-facing projection of one registered tool offered to the
|
||||
// model. Schema is the JSON-Schema of the call arguments; Dispatchable is false
|
||||
// for a listing-only entry; Activated is true only for tools the org turned on.
|
||||
|
||||
@@ -395,6 +395,38 @@ func TestRequiresMessages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpstreamBillingPassthrough: a completion refused for the caller's own reason
|
||||
// (402 insufficient_balance) reaches the caller VERBATIM — status + body — not a 502.
|
||||
func TestUpstreamBillingPassthrough(t *testing.T) {
|
||||
billing := []byte(`{"error":{"message":"Insufficient balance. Add credits.","code":"insufficient_balance"}}`)
|
||||
comp := &stubCompleter{err: &UpstreamError{Status: http.StatusPaymentRequired, Body: billing}}
|
||||
app := newApp(t, comp, &stubPlane{known: map[string]bool{}})
|
||||
status, raw := do(t, app, http.MethodPost, "/v1/agent", "acme", map[string]any{
|
||||
"preset": "graph",
|
||||
"messages": []inMessage{{Role: "user", Content: "hi"}},
|
||||
})
|
||||
if status != http.StatusPaymentRequired {
|
||||
t.Fatalf("billing refusal: want 402 passthrough, got %d: %s", status, raw)
|
||||
}
|
||||
if !bytes.Equal(raw, billing) {
|
||||
t.Fatalf("billing body not verbatim: got %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpstreamServerErrorIs502: a non-4xx upstream failure (5xx / transport) IS a
|
||||
// gateway fault and stays 502 — only a caller-facing 4xx passes through.
|
||||
func TestUpstreamServerErrorIs502(t *testing.T) {
|
||||
comp := &stubCompleter{err: &UpstreamError{Status: http.StatusServiceUnavailable, Body: []byte("upstream down")}}
|
||||
app := newApp(t, comp, &stubPlane{known: map[string]bool{}})
|
||||
status, _ := do(t, app, http.MethodPost, "/v1/agent", "acme", map[string]any{
|
||||
"preset": "graph",
|
||||
"messages": []inMessage{{Role: "user", Content: "hi"}},
|
||||
})
|
||||
if status != http.StatusBadGateway {
|
||||
t.Fatalf("5xx upstream: want 502, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPresetsLibrary: the preset library is listed and seeded with graph + create.
|
||||
func TestPresetsLibrary(t *testing.T) {
|
||||
app := newApp(t, &stubCompleter{resp: completion("x")}, &stubPlane{known: map[string]bool{}})
|
||||
|
||||
@@ -3,6 +3,7 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -101,6 +102,15 @@ func (s *Service) handleRun(c *zip.Ctx) error {
|
||||
|
||||
resp, err := s.completer.Complete(ctx, p.Cred, req)
|
||||
if err != nil {
|
||||
// A completion refused for the caller's OWN reason (402 insufficient_balance,
|
||||
// 429 rate-limit, 403) is the caller's error, not a gateway fault — pass its
|
||||
// status + body through verbatim so the client shows the real billing message,
|
||||
// never an opaque 502. Any other failure (5xx, transport) IS a gateway fault.
|
||||
var up *UpstreamError
|
||||
if errors.As(err, &up) && up.Status >= 400 && up.Status < 500 {
|
||||
c.SetHeader("Content-Type", "application/json")
|
||||
return c.Bytes(up.Status, up.Body)
|
||||
}
|
||||
return zip.Errorf(http.StatusBadGateway, "agent: completion: %v", err)
|
||||
}
|
||||
if len(resp.Choices) == 0 {
|
||||
|
||||
Reference in New Issue
Block a user