readSSE reads OpenAI SSE frames until [DONE] or EOF, forwarding content/reasoning/tool-call events to out. Returns the final assistant message (content + accumulated tool calls), the server completion and prompt token counts, and any scanner error. parent is threaded through so sends abort on cancel
(parent context.Context, body io.Reader, budget cloud.BudgetStatus, out chan<- Event, onFrame func())
| 466 | // Drain before the deferred Close so the keep-alive connection returns |
| 467 | // to the pool instead of being discarded, same as the 402/default arms. |
| 468 | _, _ = io.Copy(io.Discard, resp.Body) |
| 469 | return nil, cloud.BudgetStatus{}, nil, cloud.ErrUnauthorized |
| 470 | case 402: |
| 471 | // Pass depleted. Body ignored: the status code is the whole signal, |
| 472 | // the UI banner is fixed text, the returned snapshot reflects it. |
| 473 | _, _ = io.Copy(io.Discard, resp.Body) |
| 474 | return nil, cloud.BudgetStatus{Set: true, Remaining: 0}, nil, cloud.ErrBudgetExhausted |
| 475 | default: |
| 476 | b, _ := io.ReadAll(resp.Body) |
| 477 | return nil, cloud.BudgetStatus{}, b, &httpStatusError{status: resp.StatusCode, msg: errorMessageFromBody(b)} |
| 478 | } |
| 479 | } |
| 480 | |
| 481 | // httpStatusError preserves the HTTP status behind the user-facing message so |
| 482 | // retryable can classify transience; Error() keeps the exact "status: message" |
| 483 | // string the error banner has always shown. |
| 484 | type httpStatusError struct { |
| 485 | status int |
| 486 | msg string |
| 487 | } |
| 488 | |
| 489 | func (e *httpStatusError) Error() string { return fmt.Sprintf("%d: %s", e.status, e.msg) } |
| 490 | |
| 491 | // retryable reports whether a pre-stream failure is worth resending: transport |
| 492 | // errors and the statuses that signal a transient server state. 404 is |
| 493 | // semantically permanent, but LiteLLM-style proxies emit it for transient |
| 494 | // upstream misses (the Agnes-AI freeze in issue #7), and the rising backoff |
| 495 | // bounds the cost of a truly permanent one. 401/402 arrive as typed sentinels |
| 496 | // the UI handles; 400 and other client errors fail identically on every |
| 497 | // resend. |
| 498 | func retryable(err error) bool { |
| 499 | var un cloud.ErrUnreachable |
| 500 | if errors.As(err, &un) { |
| 501 | return true |
| 502 | } |
| 503 | var hs *httpStatusError |
| 504 | if errors.As(err, &hs) { |
| 505 | return hs.status == 404 || hs.status == 408 || hs.status == 429 || hs.status >= 500 |
| 506 | } |
| 507 | return false |
| 508 | } |
| 509 | |
| 510 | // errorMessageFromBody extracts the user-facing string from a non-2xx body. |
| 511 | // hamrpass wraps errors as `{"error":{"message":...,"provider_hint":...}}`; we |
| 512 | // prefer provider_hint (providers stash the human diagnostic there), fall back |
| 513 | // to message, then to the raw first line so non-hamrpass backends still surface |
| 514 | // whatever they emit. |
| 515 | func errorMessageFromBody(b []byte) string { |
| 516 | var env struct { |
| 517 | Error struct { |
| 518 | Message string `json:"message"` |
| 519 | ProviderHint string `json:"provider_hint"` |
| 520 | } `json:"error"` |
| 521 | } |
| 522 | if json.Unmarshal(b, &env) == nil { |
| 523 | if env.Error.ProviderHint != "" { |
| 524 | return env.Error.ProviderHint |
| 525 | } |
no test coverage detected