распознавание: большие раздачи размечаются целиком, файлы вне плана видны
- модель адресует файл номером строки нашего списка вместо копии пути: ответ на 180 файлов вместо ~15k токенов стоит ~2.5k, усечение сотней снято, max_files и max_tokens ушли в [recognition], correction-ретрай больше не переприсылает список - негодный элемент ответа отбрасывается поимённой причиной, обрыв генерации и отказ по размеру запроса названы своими причинами, покрытие плана блокирует авто только при непокрытом видеофайле - раскладка показывает все файлы раздачи со строками «не в плане» и полным порядком сортировки; снимок списка файлов лёг рядом с планом (миграция 0012)
This commit is contained in:
@@ -54,8 +54,17 @@ type Response struct {
|
||||
Content string
|
||||
Model string
|
||||
Usage Usage
|
||||
// FinishReason — причина завершения генерации, как её назвал провайдер
|
||||
// ("stop", "length", …). Пусто — провайдер её не сообщил. Нужна
|
||||
// вызывающему: обрыв по длине неотличим от мусора по одному лишь телу
|
||||
// ответа, а повторять такой запрос бессмысленно (см. recognize).
|
||||
FinishReason string
|
||||
}
|
||||
|
||||
// FinishLength — значение FinishReason, которым OpenAI-совместимые провайдеры
|
||||
// сообщают обрыв генерации по достижении предела токенов.
|
||||
const FinishLength = "length"
|
||||
|
||||
// Provider — абстракция доступа к LLM. recognize работает только с ним и не
|
||||
// знает про конкретный транспорт.
|
||||
type Provider interface {
|
||||
@@ -75,6 +84,17 @@ type Config struct {
|
||||
// ErrUnknownType — запрошенный [llm].type не поддерживается.
|
||||
var ErrUnknownType = errors.New("llm: unknown provider type")
|
||||
|
||||
// ErrRequestTooLarge — провайдер отказал из-за размера самого запроса
|
||||
// (переполнение контекста), а не из-за его содержимого. Вызывающему это
|
||||
// отдельная ветвь: такой отказ не лечится повтором, а называется человеку
|
||||
// своей причиной и крутится настройкой ([recognition].max_files).
|
||||
//
|
||||
// Признак ставит транспорт — там, где ещё целы HTTP-статус и полное тело
|
||||
// ответа (см. openai.go). Вызывающий проверяет его errors.Is: у итоговой
|
||||
// ошибки текст обрезан и в него попадает эхо запроса, так что матчить по нему
|
||||
// нельзя (docs/conventions/errors.md).
|
||||
var ErrRequestTooLarge = errors.New("llm: request too large")
|
||||
|
||||
// New собирает провайдер по дискриминатору cfg.Type. logger nil → slog.Default().
|
||||
func New(cfg Config, logger *slog.Logger) (Provider, error) {
|
||||
if logger == nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package llm
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -234,3 +235,72 @@ func TestNew_OpenAICompatValidation(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Признак обрыва генерации по длине доходит до вызывающего: по одному телу
|
||||
// ответа обрыв неотличим от мусора, а повторять такой запрос бессмысленно.
|
||||
func TestComplete_FinishReasonReachesCaller(t *testing.T) {
|
||||
for _, want := range []string{"length", "stop"} {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"model":"m","choices":[{"message":{"content":"{\"type\":"},`+
|
||||
`"finish_reason":"`+want+`"}]}`)
|
||||
}))
|
||||
|
||||
p := newTestProvider(t, srv.URL, "")
|
||||
resp, err := p.Complete(context.Background(), Request{
|
||||
Messages: []Message{{Role: RoleUser, Content: "hi"}},
|
||||
})
|
||||
srv.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("Complete: %v", err)
|
||||
}
|
||||
if resp.FinishReason != want {
|
||||
t.Errorf("finish_reason = %q, want %q", resp.FinishReason, want)
|
||||
}
|
||||
}
|
||||
if FinishLength != "length" {
|
||||
t.Errorf("FinishLength = %q, want length", FinishLength)
|
||||
}
|
||||
}
|
||||
|
||||
// Отказ по размеру запроса помечается sentinel'ом там, где ещё целы
|
||||
// HTTP-статус и ПОЛНОЕ тело: вызывающему нечего матчить по тексту, а текст
|
||||
// итоговой ошибки обрезан (snippet) и несёт эхо запроса.
|
||||
func TestComplete_RequestTooLargeSentinel(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
want bool
|
||||
}{
|
||||
{"413 без разбора текста", http.StatusRequestEntityTooLarge, `payload too big`, true},
|
||||
{"400 с маркером в теле", http.StatusBadRequest,
|
||||
`{"error":{"message":"This model's maximum context length is 128000 tokens"}}`, true},
|
||||
{"400 с маркером за пределом snippet", http.StatusBadRequest,
|
||||
`{"echo":"` + strings.Repeat("x", 400) + `","error":{"message":"prompt is too long"}}`, true},
|
||||
{"400 по другой причине", http.StatusBadRequest,
|
||||
`{"error":{"message":"invalid api key"}}`, false},
|
||||
{"500 — транзиентный сбой, не размер", http.StatusInternalServerError,
|
||||
`context length exceeded`, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(tc.status)
|
||||
_, _ = io.WriteString(w, tc.body)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := newTestProvider(t, srv.URL, "")
|
||||
_, err := p.Complete(context.Background(), Request{
|
||||
Messages: []Message{{Role: RoleUser, Content: "hi"}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка")
|
||||
}
|
||||
if got := errors.Is(err, ErrRequestTooLarge); got != tc.want {
|
||||
t.Errorf("errors.Is(err, ErrRequestTooLarge) = %v, want %v (err = %v)",
|
||||
got, tc.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+38
-4
@@ -193,8 +193,11 @@ func (c *openAICompat) do(ctx context.Context, body []byte) (Response, bool, err
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
retryable := resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500
|
||||
return Response{}, retryable, fmt.Errorf("llm: status %d: %s",
|
||||
resp.StatusCode, snippet(raw))
|
||||
err := fmt.Errorf("llm: status %d: %s", resp.StatusCode, snippet(raw))
|
||||
if requestTooLarge(resp.StatusCode, raw) {
|
||||
err = fmt.Errorf("%w: %w", ErrRequestTooLarge, err)
|
||||
}
|
||||
return Response{}, retryable, err
|
||||
}
|
||||
|
||||
var cr chatResponse
|
||||
@@ -209,8 +212,9 @@ func (c *openAICompat) do(ctx context.Context, body []byte) (Response, bool, err
|
||||
}
|
||||
|
||||
return Response{
|
||||
Content: cr.Choices[0].Message.Content,
|
||||
Model: cr.Model,
|
||||
Content: cr.Choices[0].Message.Content,
|
||||
Model: cr.Model,
|
||||
FinishReason: cr.Choices[0].FinishReason,
|
||||
Usage: Usage{
|
||||
PromptTokens: cr.Usage.PromptTokens,
|
||||
CompletionTokens: cr.Usage.CompletionTokens,
|
||||
@@ -236,6 +240,36 @@ func (c *openAICompat) wait(ctx context.Context, attempt int) error {
|
||||
}
|
||||
}
|
||||
|
||||
// tooLargeMarkers — текстовые признаки переполнения контекста в теле отказа.
|
||||
// Единого машинного кода у OpenAI-совместимых шлюзов нет: часть отдаёт 413,
|
||||
// остальные кладут причину в текст при обычном 400.
|
||||
var tooLargeMarkers = []string{
|
||||
"context length", "context_length", "context window", "maximum context",
|
||||
"too many tokens", "prompt is too long", "request too large",
|
||||
"reduce the length",
|
||||
}
|
||||
|
||||
// requestTooLarge — отказал ли провайдер из-за размера запроса. 413 —
|
||||
// безусловно, без разбора текста. 400 — только по маркерам, и смотрим их в
|
||||
// ПОЛНОМ теле: обрезка тела (snippet) выбрасывает причину ровно у тех шлюзов,
|
||||
// которые сперва echo'ят запрос. Ложное срабатывание безопасно: задача уходит
|
||||
// в review, а не в повтор.
|
||||
func requestTooLarge(status int, body []byte) bool {
|
||||
if status == http.StatusRequestEntityTooLarge {
|
||||
return true
|
||||
}
|
||||
if status != http.StatusBadRequest {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(string(body))
|
||||
for _, marker := range tooLargeMarkers {
|
||||
if strings.Contains(lower, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// snippet обрезает тело для сообщения об ошибке.
|
||||
func snippet(b []byte) string {
|
||||
const max = 300
|
||||
|
||||
Reference in New Issue
Block a user