# Subscription-first routing (Українська) 🌐 **Languages:** 🇺🇸 [English](../../../../routing/SUBSCRIPTION_LADDER.md) · 🇪🇹 [am](../../../am/docs/routing/SUBSCRIPTION_LADDER.md) · 🇸🇦 [ar](../../../ar/docs/routing/SUBSCRIPTION_LADDER.md) · 🇦🇿 [az](../../../az/docs/routing/SUBSCRIPTION_LADDER.md) · 🇧🇬 [bg](../../../bg/docs/routing/SUBSCRIPTION_LADDER.md) · 🇧🇩 [bn](../../../bn/docs/routing/SUBSCRIPTION_LADDER.md) · 🇧🇦 [bs](../../../bs/docs/routing/SUBSCRIPTION_LADDER.md) · 🇨🇿 [cs](../../../cs/docs/routing/SUBSCRIPTION_LADDER.md) · 🇩🇰 [da](../../../da/docs/routing/SUBSCRIPTION_LADDER.md) · 🇩🇪 [de](../../../de/docs/routing/SUBSCRIPTION_LADDER.md) · 🇬🇷 [el](../../../el/docs/routing/SUBSCRIPTION_LADDER.md) · 🇪🇸 [es](../../../es/docs/routing/SUBSCRIPTION_LADDER.md) · 🇪🇪 [et](../../../et/docs/routing/SUBSCRIPTION_LADDER.md) · 🇮🇷 [fa](../../../fa/docs/routing/SUBSCRIPTION_LADDER.md) · 🇫🇮 [fi](../../../fi/docs/routing/SUBSCRIPTION_LADDER.md) · 🇫🇷 [fr](../../../fr/docs/routing/SUBSCRIPTION_LADDER.md) · 🇮🇪 [ga](../../../ga/docs/routing/SUBSCRIPTION_LADDER.md) · 🇮🇳 [gu](../../../gu/docs/routing/SUBSCRIPTION_LADDER.md) · 🇳🇬 [ha](../../../ha/docs/routing/SUBSCRIPTION_LADDER.md) · 🇮🇱 [he](../../../he/docs/routing/SUBSCRIPTION_LADDER.md) · 🇮🇳 [hi](../../../hi/docs/routing/SUBSCRIPTION_LADDER.md) · 🇭🇷 [hr](../../../hr/docs/routing/SUBSCRIPTION_LADDER.md) · 🇭🇺 [hu](../../../hu/docs/routing/SUBSCRIPTION_LADDER.md) · 🇦🇲 [hy](../../../hy/docs/routing/SUBSCRIPTION_LADDER.md) · 🇮🇩 [id](../../../id/docs/routing/SUBSCRIPTION_LADDER.md) · 🇳🇬 [ig](../../../ig/docs/routing/SUBSCRIPTION_LADDER.md) · 🇮🇹 [it](../../../it/docs/routing/SUBSCRIPTION_LADDER.md) · 🇯🇵 [ja](../../../ja/docs/routing/SUBSCRIPTION_LADDER.md) · 🇬🇪 [ka](../../../ka/docs/routing/SUBSCRIPTION_LADDER.md) · 🇰🇭 [km](../../../km/docs/routing/SUBSCRIPTION_LADDER.md) · 🇮🇳 [kn](../../../kn/docs/routing/SUBSCRIPTION_LADDER.md) · 🇰🇷 [ko](../../../ko/docs/routing/SUBSCRIPTION_LADDER.md) · 🇱🇹 [lt](../../../lt/docs/routing/SUBSCRIPTION_LADDER.md) · 🇱🇻 [lv](../../../lv/docs/routing/SUBSCRIPTION_LADDER.md) · 🇮🇳 [ml](../../../ml/docs/routing/SUBSCRIPTION_LADDER.md) · 🇮🇳 [mr](../../../mr/docs/routing/SUBSCRIPTION_LADDER.md) · 🇲🇾 [ms](../../../ms/docs/routing/SUBSCRIPTION_LADDER.md) · 🇲🇹 [mt](../../../mt/docs/routing/SUBSCRIPTION_LADDER.md) · 🇲🇲 [my](../../../my/docs/routing/SUBSCRIPTION_LADDER.md) · 🇳🇵 [ne](../../../ne/docs/routing/SUBSCRIPTION_LADDER.md) · 🇳🇱 [nl](../../../nl/docs/routing/SUBSCRIPTION_LADDER.md) · 🇳🇴 [no](../../../no/docs/routing/SUBSCRIPTION_LADDER.md) · 🇮🇳 [or](../../../or/docs/routing/SUBSCRIPTION_LADDER.md) · 🇮🇳 [pa](../../../pa/docs/routing/SUBSCRIPTION_LADDER.md) · 🇵🇭 [phi](../../../phi/docs/routing/SUBSCRIPTION_LADDER.md) · 🇵🇱 [pl](../../../pl/docs/routing/SUBSCRIPTION_LADDER.md) · 🇵🇹 [pt](../../../pt/docs/routing/SUBSCRIPTION_LADDER.md) · 🇧🇷 [pt-BR](../../../pt-BR/docs/routing/SUBSCRIPTION_LADDER.md) · 🇷🇴 [ro](../../../ro/docs/routing/SUBSCRIPTION_LADDER.md) · 🇷🇺 [ru](../../../ru/docs/routing/SUBSCRIPTION_LADDER.md) · 🇱🇰 [si](../../../si/docs/routing/SUBSCRIPTION_LADDER.md) · 🇸🇰 [sk](../../../sk/docs/routing/SUBSCRIPTION_LADDER.md) · 🇸🇮 [sl](../../../sl/docs/routing/SUBSCRIPTION_LADDER.md) · 🇷🇸 [sr](../../../sr/docs/routing/SUBSCRIPTION_LADDER.md) · 🇸🇪 [sv](../../../sv/docs/routing/SUBSCRIPTION_LADDER.md) · 🇰🇪 [sw](../../../sw/docs/routing/SUBSCRIPTION_LADDER.md) · 🇮🇳 [ta](../../../ta/docs/routing/SUBSCRIPTION_LADDER.md) · 🇮🇳 [te](../../../te/docs/routing/SUBSCRIPTION_LADDER.md) · 🇹🇭 [th](../../../th/docs/routing/SUBSCRIPTION_LADDER.md) · 🇹🇷 [tr](../../../tr/docs/routing/SUBSCRIPTION_LADDER.md) · 🇵🇰 [ur](../../../ur/docs/routing/SUBSCRIPTION_LADDER.md) · 🇺🇿 [uz](../../../uz/docs/routing/SUBSCRIPTION_LADDER.md) · 🇻🇳 [vi](../../../vi/docs/routing/SUBSCRIPTION_LADDER.md) · 🇳🇬 [yo](../../../yo/docs/routing/SUBSCRIPTION_LADDER.md) · 🇨🇳 [zh-CN](../../../zh-CN/docs/routing/SUBSCRIPTION_LADDER.md) · 🇹🇼 [zh-TW](../../../zh-TW/docs/routing/SUBSCRIPTION_LADDER.md) --- > Два нові ідентифікатори `auto/*` — `auto/subscription` і `auto/thrifty`. Обидва активуються лише > за явним запитом: нічого не маршрутизується через них, якщо викликач не запитає ідентифікатор за > назвою, і жоден наявний пул, стратегія чи значення за замовчуванням не змінюються. ## Навіщо це потрібно OmniRoute вже відповідає на два запитання щодо вартості, але жодне з них не є тим, яке ставить більшість операторів. | Наявний механізм | На яке запитання відповідає | | -------------------------------------------------------- | ------------------------------------------------------------ | | `hidePaidModels` (`autoCombo/paidModelFilter.ts`) | «чи позначена ця модель у каталозі як безкоштовна?» | | `freeAccessPolicy: "strict"` (`strictZeroCostFilter.ts`) | «чи може це підключення коли-небудь виставити мені рахунок?» | | `quotaPreflight` (`combo/quotaExhaustionCutoff.ts`) | «чи наближається це підключення до свого ліміту?» | | `budgetCap` / `budgetFallback` (`autoCombo/engine.ts`) | «обмежити витрати, перейти до найдешевшого варіанта» | Кожен механізм, орієнтований лише на безкоштовні варіанти, **завершується відмовою** — вичерпаний безкоштовний пул стає порожнім пулом, а не переходом до платного варіанта — і кожен механізм платної сторони не враховує рівні. Жоден із них не відповідає: > «Використовуй квоту, за яку я вже плачу. Коли вона закінчиться, або зупинись, або переходь > по одному щаблю до найдешевших платних варіантів — і повернись одразу після її відновлення». ## Тарифікація є властивістю підключення, а не моделі `classifyTier()` (`open-sse/services/tierResolver.ts`) використовує ключ `(provider, model)` і повертає `free | cheap | premium` на основі каталожних цін. Але те, чи спричиняє запит додаткові витрати, залежить від того, **яке підключення його обслуговує**: та сама модель входить у план через OAuth-підключення Claude Code і тарифікується за токен через підключення з API-ключем. `provider_connections.auth_type` не є надійним непрямим показником у жодному напрямку — існують тарифіковані OAuth-підключення та API-підключення, що входять у план (токен місця Copilot не є тарифікованим API-ключем). Тому клас тарифікації береться з **курованого каталогу**, `open-sse/config/connectionBillingCatalog.ts`, налаштованого вручну на основі опублікованих умов кожного провайдера — за тим самим принципом, який `FreeModelBudget.hardStopGuaranteed` уже встановив для безкоштовних моделей. ```ts type ConnectionBillingClass = "subscription" | "metered" | "keyless" | "unknown"; type ConnectionOverageBehavior = "hard-stop" | "meters-to-paid" | "unknown"; ``` Порядок визначення (`autoCombo/connectionBilling.ts`): синтетичний маркер відсутності автентифікації → `keyless`; запис каталогу, що відповідає і провайдеру, і `authType`; запис для всього провайдера; інакше — `unknown`. **Некуроване не означає безкоштовне** — `unknown` всюди обробляється як `metered`, тож провайдер, доданий завтра, спочатку опиниться поза щаблем підписки й має бути включений до нього свідомо. ## Модель щаблів П'ять щаблів у порядку підвищення. Вони відрізняються не лише ціною — кожен має **власний** сигнал вичерпання, тому це не просто сортування. | # | Щабель | Умови належності | Коли вважається вичерпаним | | --- | -------------- | -------------------------------------------------------- | ------------------------------------------- | | 0 | `subscription` | куроване `billing: "subscription"` | вікно квоти на рівні порога або нижче | | 1 | `keyless` | синтетичний шлях без автентифікації | період відновлення підключення / запобіжник | | 2 | `free` | тарифіковане підключення, `classifyTier() === "free"` | безкоштовний ліміт вичерпано | | 3 | `cheap` | тарифіковане підключення, `classifyTier() === "cheap"` | бюджет щабля вичерпано | | 4 | `premium` | тарифіковане підключення, `classifyTier() === "premium"` | бюджет щабля вичерпано | Щаблі 0–2 вичерпуються за **квотою**, яку можна спостерігати і яка вже відстежується. Щаблі 3–4 не мають квоти — платне підключення може обслуговувати запити безкінечно — тому єдиним розумним сигналом їх вичерпання є **бюджет** для кожного щабля. Без нього «підвищити рівень, коли дешевий варіант вичерпано» не має умови спрацювання. ## `auto/subscription` — відмова в безпечний бік Пул = лише рівень 0, обмежений з’єднаннями, для яких перевищення квоти задокументоване як `hard-stop` і для кожного з яких під час перевірки в реальному часі підтверджено наявність запасу квоти. Усе неоднозначне виключається: постачальник без ручної перевірки, показник квоти, який неможливо перевірити, застарілий показник або перевищення квоти, що тарифікується як платне. Порожній пул — це **очікуваний** результат, а не дефект: наявний у викликачі шлях обробки порожнього пулу перетворює його на зрозумілу помилку, а не на непомітний платний резервний варіант. У цьому й полягає вся гарантія цього ідентифікатора. `keyless` навмисно **не** відповідає умовам: це групування означає «план, за який я плачу», тому бекенд без автентифікації до нього не належить. Для цього використовуйте `auto/thrifty` (або `auto/best-free`). ### Безпека з’єднань Кандидат не завжди прив’язаний до одного з’єднання — логічний кандидат містить список дозволених значень `allowedConnectionIds`, а обліковий запис, який фактично використовуватиметься, вибирається пізніше, під час диспетчеризації, у `open-sse/services/combo/autoStrategy.ts`. Тому обидва групування перевіряють **кожне з’єднання окремо** та скорочують `allowedConnectionIds` рівно до підмножини, що пройшла перевірку, — ніколи не залишають повний початковий список і не вибирають довільно один його елемент. Оскільки `autoStrategy.ts` уже застосовує цей масив як жорсткий список дозволених значень, його перезапис тут конструктивно гарантує, що множини «перевірених» і «фактично використаних» з’єднань збігаються. Це той самий інваріант і те саме обґрунтування, що й у [STRICT_ZERO_COST](./STRICT_ZERO_COST.md). ## `auto/thrifty` — підвищення на один рівень за раз Пул = усі рівні, упорядковані за індексом рівня, причому вичерпані кандидати відсіюються. Механізм `auto` і далі виконує оцінювання **в межах** пулу, що залишився: драбина визначає, які рівні беруть участь, а оцінювання — який кандидат перемагає серед них. Порядок у межах рівня є стабільним, тому це накладання ніколи не змінює власне ранжування оцінювача. Це накладання впорядкування та відсіювання, а **не** новий диспетчер: спекулятивний цикл у `combo.ts` уже перебирає цілі по порядку та в разі невдачі переходить до наступної, тому вичерпання під час виконання, якого не виявила попередня перевірка, усе одно спричиняє перехід до наступного рівня в межах того самого запиту. Якщо `auto/subscription` відмовляє **в безпечний бік**, то `auto/thrifty` відмовляє **у відкритий бік**: з’єднання, включене до плану, для якого немає придатного показника квоти, усе одно випробовується першим. Така спроба нічого не коштує, і якщо квота виявиться вичерпаною, перехід у разі невдачі все одно досягне наступного рівня. Натомість відмова від такої спроби через відсутність телеметрії спрямувала б запит на платний рівень — саме цього результату й покликане уникнути це групування. ## Повернення до плану після скидання Перш ніж маршрутизація повернеться на рівень 0, мають незалежно завершитися три стани. Виправлення лише одного з них залишає драбину заблокованою на платних рівнях ще довго після поповнення плану. 1. **Кеш стану квоти** — `freeAccessQuota.ts` кешує дані для кожної пари `(provider, connection)` з TTL у 180 с. Кешований запис, власний час `resetAt` якого вже минув, описує вікно, що більше не існує, тому тепер він вважається застарілим **незалежно від віку** та примусово оновлюється. Без цього план, квоту якого було поповнено опівночі, і далі відображався б як вичерпаний, доки випадково не спливе TTL. 2. **Власний стан драбини** — за задумом його немає. Відповідність рівнів умовам обчислюється повторно на основі актуального стану квоти під час кожної побудови пулу; не існує збереженого запису «зараз на рівні 3», який міг би пережити скидання та заблокувати маршрутизацію. 3. **Період очікування з’єднання** — відповідь 429, що свідчить про вичерпання, установлює `rateLimitedUntil` на основі експоненційної затримки, яка для з’єднання плану може перевищити фактичний час скидання. `clampCooldownToReset()` (`subscriptionLadder.ts`) скорочує період очікування до моменту скидання на стороні постачальника й ніколи не може його подовжити. **Функцію реалізовано й протестовано, але ще не підключено**: кеш квоти анулюється в `src/sse/services/auth.ts` _до_ запису будь-якого періоду очікування, тому `resetAt` потрібно отримати раніше в цій функції — це зміна критичного шляху відмовостійкості, яку слід винести в окремий PR для перевірки. До того часу повторний допуск очікує завершення періоду очікування з’єднання (який уже надає перевагу підказкам `Retry-After` від постачальника, якщо той їх надсилає). ### Захист від коливань Рівень, квоту якого щойно скинуто, повторно допускається лише за умови перевищення `reentryMinRemainingPercent` (типове значення — 5), тоді як з’єднанню, що вже використовується, достатньо залишатися вище `exitCutoffPercent` (типове значення — 2, що відповідає `quotaPreflight.defaultThresholdPercent`). Різниця між ними є смугою гістерезису — без неї з’єднання, що коливається поблизу порогового значення, перемикалося б між рівнями в послідовних запитах. ## Конфігурація Лише налаштування. Навмисно **немає** прапорця `enabled`: перемикач, здатний вимкнути ці рівні, призвів би до того, що `auto/subscription` непомітно надавав би весь пул — включно з платними моделями — під назвою, яка обіцяє протилежне. ```jsonc { "subscriptionLadder": { "exitCutoffPercent": 2, "reentryMinRemainingPercent": 5, "rungBudgetUsd": { "cheap": 5.0, "premium": 0 }, // 0 повністю вимикає рівень }, } ``` Обмеження за бюджетом неактивне, доки не підключено засіб визначення витрат: за відсутності даних обліку платний рівень упорядковується, але ніколи не обмежується. Починаючи з v3.8.51, налаштування `rungBudgetUsd` приймається схемою, але ще НЕ застосовується — вважайте його зарезервованою конфігурацією, а не активним обмеженням витрат. Упорядкування рівнів, вичерпання на основі квот і повторний вхід після скидання працюють і без нього. ## Компонування `subscription` і `thrifty` є значеннями `AutoTier`, тому вони компонуються з кожною категорією: `auto/coding:thrifty`, `auto/reasoning:subscription` тощо. Два прості ідентифікатори (`auto/subscription`, `auto/thrifty`) представлені в `/v1/models` і на панелі керування. Жоден з ідентифікаторів не належить до платного рівня, тому `isPaidTierAutoId()` повертає `false` для обох, а `auto/subscription` не приховується через `hidePaidModels`. ## Розташування коду | Компонент | Файл | | ---------------------------------------- | --------------------------------------------------- | | Відібрані факти білінгу | `open-sse/config/connectionBillingCatalog.ts` | | Класифікатор | `open-sse/services/autoCombo/connectionBilling.ts` | | Рівні, обидва групування, повторний вхід | `open-sse/services/autoCombo/subscriptionLadder.ts` | | Підключення до пулу кандидатів | `open-sse/services/autoCombo/virtualFactory.ts` | | Застарілість кешу з урахуванням скидання | `open-sse/services/autoCombo/freeAccessQuota.ts` | | Поверхня рівнів | `open-sse/services/autoCombo/suffixComposition.ts` | | Оголошені ідентифікатори | `open-sse/services/autoCombo/builtinCatalog.ts` | | Тести | `tests/unit/autoCombo/subscription-ladder.test.ts` |