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 | // own name in each dialect (OpenAI "reasoning.effort", vLLM "Unexpected |
| 467 | // reasoning effort", Ollama's "<model> does not support thinking"), never on a |
| 468 | // lone generic word, so an unrelated 400 that merely mentions "thinking" can't |
| 469 | // latch reasoning off for the Client's whole life. Dropping the field is the |
| 470 | // right remedy for all of them, but note what it costs: the server then applies |
| 471 | // its own default (on the Qwen3.8 scale that is xhigh, i.e. MORE reasoning than |
| 472 | // we asked for, not less). A compatibility fix, never a way to think less. |
| 473 | func rejectsReasoning(errBody []byte) bool { |
| 474 | for _, sig := range []string{"reasoning.effort", "reasoning_effort", "reasoning effort", "does not support thinking"} { |
| 475 | if bytes.Contains(errBody, []byte(sig)) { |
| 476 | return true |
| 477 | } |
| 478 | } |
| 479 | return false |
| 480 | } |
| 481 | |
| 482 | // doPost performs one round-trip, mapping status into the typed cloud errors |
| 483 | // Probe and sendChat share. On 200 it returns the live response with body open |
| 484 | // for streaming; on non-200 the body is drained and closed first. Budget is set |
| 485 | // only on 402. errBody returns the raw body on a non-2xx other than 401/402, so |
| 486 | // post can check it for the reasoning fallback signal without re-reading. |
| 487 | func (c *Client) doPost(parent context.Context, body request) (*http.Response, cloud.BudgetStatus, []byte, error) { |
| 488 | buf, err := json.Marshal(body) |
| 489 | if err != nil { |
| 490 | return nil, cloud.BudgetStatus{}, nil, err |
| 491 | } |
| 492 | req, err := http.NewRequestWithContext(parent, "POST", c.BaseURL+"/v1/responses", bytes.NewReader(buf)) |
| 493 | if err != nil { |
| 494 | return nil, cloud.BudgetStatus{}, nil, err |
| 495 | } |
| 496 | req.Header.Set("Content-Type", "application/json") |
| 497 | req.Header.Set("Accept", "text/event-stream") |
| 498 | if c.Token != "" { |
| 499 | req.Header.Set("Authorization", cloud.AuthHeader(c.Token)) |
| 500 | } |
| 501 | resp, err := c.HTTP.Do(req) |
| 502 | if err != nil { |
| 503 | return nil, cloud.BudgetStatus{}, nil, cloud.ErrUnreachable{Err: err} |
| 504 | } |
| 505 | if resp.StatusCode == 200 { |
| 506 | return resp, cloud.BudgetStatus{}, nil, nil |
| 507 | } |
| 508 | defer resp.Body.Close() |
| 509 | switch resp.StatusCode { |
| 510 | case 401: |
| 511 | // Drain before the deferred Close so the keep-alive connection returns |
| 512 | // to the pool instead of being discarded, same as the 402/default arms. |
| 513 | _, _ = io.Copy(io.Discard, resp.Body) |
| 514 | return nil, cloud.BudgetStatus{}, nil, cloud.ErrUnauthorized |
| 515 | case 402: |
| 516 | // Pass depleted. Body ignored: the status code is the whole signal, |
| 517 | // the UI banner is fixed text, the returned snapshot reflects it. |
| 518 | _, _ = io.Copy(io.Discard, resp.Body) |
| 519 | return nil, cloud.BudgetStatus{Set: true, Remaining: 0}, nil, cloud.ErrBudgetExhausted |
| 520 | default: |
| 521 | b, _ := io.ReadAll(resp.Body) |
| 522 | msg := errorMessageFromBody(b) |
| 523 | if resp.StatusCode == 404 && strings.Contains(msg, "litellm.NotFoundError") { |
| 524 | msg += " · check LiteLLM's upstream URL and model; if that upstream only supports /chat/completions, set use_chat_completions_api: true under this model's litellm_params in the proxy config" |
| 525 | } else if resp.StatusCode == 404 { |
no test coverage detected