From abb396e080d7eda0afb103a8ca2584e271ae667c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 4 Aug 2026 11:29:58 +0000 Subject: [PATCH 1/4] fix(walkthrough): block unauthenticated providers with a friendly refusal When the walkthrough small model resolves to a provider with no usable login, readiness was still ready and generate returned a raw 500 message. Refuse up front with no-provider-login and surface a blocker instead. Closes openchamber/openchamber#2607 Co-authored-by: Serhii Dziupin --- .../views/walkthrough/WalkthroughBlocker.tsx | 9 +- .../views/walkthrough/WalkthroughView.tsx | 1 + packages/ui/src/lib/i18n/messages/de.ts | 3 + packages/ui/src/lib/i18n/messages/en.ts | 3 + packages/ui/src/lib/i18n/messages/es.ts | 3 + packages/ui/src/lib/i18n/messages/fr.ts | 3 + packages/ui/src/lib/i18n/messages/ja.ts | 3 + packages/ui/src/lib/i18n/messages/ko.ts | 3 + packages/ui/src/lib/i18n/messages/pl.ts | 3 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 3 + packages/ui/src/lib/i18n/messages/uk.ts | 3 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 3 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 3 + packages/ui/src/lib/walkthrough/types.ts | 1 + .../server/lib/small-model/DOCUMENTATION.md | 15 +- packages/web/server/lib/small-model/call.js | 22 ++- .../web/server/lib/small-model/call.test.js | 11 +- packages/web/server/lib/small-model/index.js | 11 +- .../web/server/lib/small-model/index.test.js | 21 ++- .../server/lib/walkthrough/DOCUMENTATION.md | 7 + packages/web/server/lib/walkthrough/index.js | 16 ++ .../lib/walkthrough/reproduce-2607.test.js | 151 ++++++++++++++++++ 22 files changed, 286 insertions(+), 12 deletions(-) create mode 100644 packages/web/server/lib/walkthrough/reproduce-2607.test.js diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx index 2f0af021..f23696be 100644 --- a/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx +++ b/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx @@ -41,7 +41,8 @@ export const WalkthroughBlocker = ({ // Settings. const canChooseModel = reason === 'context-too-small' || reason === 'structured-output-unsupported' - || reason === 'output-exhausted'; + || reason === 'output-exhausted' + || reason === 'no-provider-login'; useEffect(() => { if (!canChooseModel || providers !== undefined) return; @@ -100,6 +101,11 @@ export const WalkthroughBlocker = ({ const description = () => { if (reason === 'no-model') return t('walkthrough.blocked.noModel.description'); + if (reason === 'no-provider-login') { + return label + ? t('walkthrough.blocked.noProviderLogin.description', { model: label }) + : t('walkthrough.blocked.noProviderLogin.descriptionUnknownModel'); + } if (reason === 'empty-diff') return t('walkthrough.blocked.emptyDiff.description'); if (reason === 'only-generated') return t('walkthrough.blocked.onlyGenerated.description'); if (reason === 'output-exhausted') { @@ -123,6 +129,7 @@ export const WalkthroughBlocker = ({ const title = () => { if (reason === 'no-model') return t('walkthrough.blocked.noModel.title'); + if (reason === 'no-provider-login') return t('walkthrough.blocked.noProviderLogin.title'); if (reason === 'empty-diff') return t('walkthrough.blocked.emptyDiff.title'); if (reason === 'only-generated') return t('walkthrough.blocked.onlyGenerated.title'); if (reason === 'output-exhausted') return t('walkthrough.blocked.outputExhausted.title'); diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx index c0403b75..15b9baa8 100644 --- a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx +++ b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx @@ -406,6 +406,7 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { const blockedReason = entry.error?.code === 'context-too-small' || entry.error?.code === 'structured-output-unsupported' || entry.error?.code === 'no-model' + || entry.error?.code === 'no-provider-login' || entry.error?.code === 'empty-diff' || entry.error?.code === 'only-generated' || entry.error?.code === 'output-exhausted' diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 0db76c86..4f353026 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -2866,6 +2866,9 @@ export const dict = { 'walkthrough.importance.context': 'Kontext', 'walkthrough.blocked.noModel.title': 'Kein Modell ausgewählt', 'walkthrough.blocked.noModel.description': 'Wählen Sie zuerst ein Modell aus.', + 'walkthrough.blocked.noProviderLogin.title': 'Dieser Anbieter ist nicht angemeldet', + 'walkthrough.blocked.noProviderLogin.description': '{model} braucht eine Anmeldung bei seinem Anbieter. Melde dich an oder wähle ein Modell eines Anbieters, den du bereits nutzt.', + 'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': 'Das ausgewählte Modell braucht eine Anmeldung bei seinem Anbieter. Melde dich an oder wähle ein Modell eines Anbieters, den du bereits nutzt.', 'walkthrough.blocked.emptyDiff.title': 'Kein Diff vorhanden', 'walkthrough.blocked.emptyDiff.description': 'Es gibt keine Änderungen, die zusammengefasst werden können.', 'walkthrough.blocked.contextTooSmall.title': 'Kontext zu klein', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 17d137e5..53929a2f 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1147,6 +1147,9 @@ export const dict = { 'walkthrough.importance.context': 'Context', 'walkthrough.blocked.noModel.title': 'No small model available', 'walkthrough.blocked.noModel.description': 'Sign in to a model provider to generate a review.', + 'walkthrough.blocked.noProviderLogin.title': 'This provider is not signed in', + 'walkthrough.blocked.noProviderLogin.description': '{model} needs a login for its provider. Sign in, or choose a model from a provider you already use.', + 'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': 'The selected model needs a login for its provider. Sign in, or choose a model from a provider you already use.', 'walkthrough.blocked.emptyDiff.title': 'Nothing to review', 'walkthrough.blocked.emptyDiff.description': 'There are no changes in this scope yet.', 'walkthrough.blocked.contextTooSmall.title': 'This diff is too large for the current model', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index ea5086d0..140e686e 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1148,6 +1148,9 @@ export const dict: Record = { "walkthrough.importance.context": "Contexto", "walkthrough.blocked.noModel.title": "No hay ningún modelo pequeño disponible", "walkthrough.blocked.noModel.description": "Inicia sesión en un proveedor de modelos para generar una revisión.", + "walkthrough.blocked.noProviderLogin.title": "Este proveedor no tiene sesión iniciada", + "walkthrough.blocked.noProviderLogin.description": "{model} necesita un inicio de sesión en su proveedor. Inicia sesión o elige un modelo de un proveedor que ya uses.", + "walkthrough.blocked.noProviderLogin.descriptionUnknownModel": "El modelo seleccionado necesita un inicio de sesión en su proveedor. Inicia sesión o elige un modelo de un proveedor que ya uses.", "walkthrough.blocked.emptyDiff.title": "Nada que revisar", "walkthrough.blocked.emptyDiff.description": "Todavía no hay cambios en este ámbito.", "walkthrough.blocked.contextTooSmall.title": "Este diff es demasiado grande para el modelo actual", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 3a625450..479033dc 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -972,6 +972,9 @@ export const dict = { 'walkthrough.importance.context': 'Contexte', 'walkthrough.blocked.noModel.title': 'Aucun petit modèle disponible', 'walkthrough.blocked.noModel.description': 'Connectez-vous à un fournisseur de modèles pour générer une revue.', + 'walkthrough.blocked.noProviderLogin.title': 'Ce fournisseur n’est pas connecté', + 'walkthrough.blocked.noProviderLogin.description': '{model} nécessite une connexion à son fournisseur. Connectez-vous, ou choisissez un modèle d’un fournisseur que vous utilisez déjà.', + 'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': 'Le modèle sélectionné nécessite une connexion à son fournisseur. Connectez-vous, ou choisissez un modèle d’un fournisseur que vous utilisez déjà.', 'walkthrough.blocked.emptyDiff.title': 'Rien à examiner', 'walkthrough.blocked.emptyDiff.description': 'Il n’y a encore aucune modification dans cette portée.', 'walkthrough.blocked.contextTooSmall.title': 'Ce diff est trop volumineux pour le modèle actuel', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 181ec3e9..ed810851 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1144,6 +1144,9 @@ export const dict: Record = { 'walkthrough.importance.context': '補足', 'walkthrough.blocked.noModel.title': '利用できるスモールモデルがありません', 'walkthrough.blocked.noModel.description': 'レビューを生成するにはモデルプロバイダーにサインインしてください。', + 'walkthrough.blocked.noProviderLogin.title': 'このプロバイダーにはサインインしていません', + 'walkthrough.blocked.noProviderLogin.description': '{model} にはプロバイダーへのログインが必要です。サインインするか、すでに使っているプロバイダーのモデルを選んでください。', + 'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': '選択したモデルにはプロバイダーへのログインが必要です。サインインするか、すでに使っているプロバイダーのモデルを選んでください。', 'walkthrough.blocked.emptyDiff.title': 'レビュー対象がありません', 'walkthrough.blocked.emptyDiff.description': 'この範囲にはまだ変更がありません。', 'walkthrough.blocked.contextTooSmall.title': 'この差分は現在のモデルには大きすぎます', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 150a56ce..d9d68816 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1148,6 +1148,9 @@ export const dict: Record = { 'walkthrough.importance.context': '참고', 'walkthrough.blocked.noModel.title': '사용할 수 있는 스몰 모델이 없습니다', 'walkthrough.blocked.noModel.description': '리뷰를 생성하려면 모델 제공자에 로그인하세요.', + 'walkthrough.blocked.noProviderLogin.title': '이 제공자에 로그인되어 있지 않습니다', + 'walkthrough.blocked.noProviderLogin.description': '{model}을(를) 쓰려면 해당 제공자에 로그인해야 합니다. 로그인하거나, 이미 사용 중인 제공자의 모델을 선택하세요.', + 'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': '선택한 모델을 쓰려면 해당 제공자에 로그인해야 합니다. 로그인하거나, 이미 사용 중인 제공자의 모델을 선택하세요.', 'walkthrough.blocked.emptyDiff.title': '리뷰할 내용이 없습니다', 'walkthrough.blocked.emptyDiff.description': '이 범위에는 아직 변경 사항이 없습니다.', 'walkthrough.blocked.contextTooSmall.title': '이 diff는 현재 모델에 너무 큽니다', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 8f93e1f6..949b745f 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1460,6 +1460,9 @@ export const dict: Record = { 'walkthrough.importance.context': 'Kontekst', 'walkthrough.blocked.noModel.title': 'Brak dostępnego małego modelu', 'walkthrough.blocked.noModel.description': 'Zaloguj się u dostawcy modeli, aby wygenerować przegląd.', + 'walkthrough.blocked.noProviderLogin.title': 'Ten dostawca nie jest zalogowany', + 'walkthrough.blocked.noProviderLogin.description': '{model} wymaga logowania u swojego dostawcy. Zaloguj się albo wybierz model u dostawcy, którego już używasz.', + 'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': 'Wybrany model wymaga logowania u swojego dostawcy. Zaloguj się albo wybierz model u dostawcy, którego już używasz.', 'walkthrough.blocked.emptyDiff.title': 'Nie ma czego przeglądać', 'walkthrough.blocked.emptyDiff.description': 'W tym zakresie nie ma jeszcze zmian.', 'walkthrough.blocked.contextTooSmall.title': 'Te różnice są za duże dla bieżącego modelu', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 44fef67a..6c79996a 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1148,6 +1148,9 @@ export const dict: Record = { "walkthrough.importance.context": "Contexto", "walkthrough.blocked.noModel.title": "Nenhum modelo pequeno disponível", "walkthrough.blocked.noModel.description": "Entre em um provedor de modelos para gerar uma revisão.", + "walkthrough.blocked.noProviderLogin.title": "Este provedor não está conectado", + "walkthrough.blocked.noProviderLogin.description": "{model} precisa de login no provedor. Entre na conta ou escolha um modelo de um provedor que você já usa.", + "walkthrough.blocked.noProviderLogin.descriptionUnknownModel": "O modelo selecionado precisa de login no provedor. Entre na conta ou escolha um modelo de um provedor que você já usa.", "walkthrough.blocked.emptyDiff.title": "Nada para revisar", "walkthrough.blocked.emptyDiff.description": "Ainda não há mudanças neste escopo.", "walkthrough.blocked.contextTooSmall.title": "Este diff é grande demais para o modelo atual", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index c8663cea..6005df66 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1148,6 +1148,9 @@ export const dict: Record = { "walkthrough.importance.context": "Контекст", "walkthrough.blocked.noModel.title": "Немає доступної small model", "walkthrough.blocked.noModel.description": "Увійдіть до провайдера моделей, щоб створити розбір.", + "walkthrough.blocked.noProviderLogin.title": "У цей провайдер не ввійшли", + "walkthrough.blocked.noProviderLogin.description": "{model} потребує входу в його провайдер. Увійдіть або виберіть модель у провайдера, яким ви вже користуєтесь.", + "walkthrough.blocked.noProviderLogin.descriptionUnknownModel": "Вибрана модель потребує входу в її провайдер. Увійдіть або виберіть модель у провайдера, яким ви вже користуєтесь.", "walkthrough.blocked.emptyDiff.title": "Немає що оглядати", "walkthrough.blocked.emptyDiff.description": "У цій області поки що немає змін.", "walkthrough.blocked.contextTooSmall.title": "Цей diff завеликий для поточної моделі", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 76ffac2f..551b95cf 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1148,6 +1148,9 @@ export const dict: Record = { 'walkthrough.importance.context': '背景', 'walkthrough.blocked.noModel.title': '没有可用的小模型', 'walkthrough.blocked.noModel.description': '请登录模型提供方后再生成评审。', + 'walkthrough.blocked.noProviderLogin.title': '尚未登录此提供方', + 'walkthrough.blocked.noProviderLogin.description': '{model} 需要登录其提供方。请先登录,或改选你已在使用的提供方中的模型。', + 'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': '所选模型需要登录其提供方。请先登录,或改选你已在使用的提供方中的模型。', 'walkthrough.blocked.emptyDiff.title': '没有可评审的内容', 'walkthrough.blocked.emptyDiff.description': '该范围内暂无改动。', 'walkthrough.blocked.contextTooSmall.title': '当前模型无法容纳这份差异', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 3538d59c..95b0af8b 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1160,6 +1160,9 @@ export const dict: Record = { 'walkthrough.importance.context': '背景', 'walkthrough.blocked.noModel.title': '沒有可用的小模型', 'walkthrough.blocked.noModel.description': '請先登入模型供應商再產生審閱。', + 'walkthrough.blocked.noProviderLogin.title': '尚未登入此供應商', + 'walkthrough.blocked.noProviderLogin.description': '{model} 需要登入其供應商。請先登入,或改選你已在使用的供應商中的模型。', + 'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': '所選模型需要登入其供應商。請先登入,或改選你已在使用的供應商中的模型。', 'walkthrough.blocked.emptyDiff.title': '沒有可審閱的內容', 'walkthrough.blocked.emptyDiff.description': '此範圍目前沒有變更。', 'walkthrough.blocked.contextTooSmall.title': '目前模型無法容納這份差異', diff --git a/packages/ui/src/lib/walkthrough/types.ts b/packages/ui/src/lib/walkthrough/types.ts index 8c6b5a23..40bfd62e 100644 --- a/packages/ui/src/lib/walkthrough/types.ts +++ b/packages/ui/src/lib/walkthrough/types.ts @@ -91,6 +91,7 @@ export type WalkthroughStage = 'collecting' | 'asking' | 'retrying' | 'assemblin export type WalkthroughBlockedReason = | 'no-model' + | 'no-provider-login' | 'empty-diff' | 'only-generated' | 'context-too-small' diff --git a/packages/web/server/lib/small-model/DOCUMENTATION.md b/packages/web/server/lib/small-model/DOCUMENTATION.md index a9d60a99..1776f1e1 100644 --- a/packages/web/server/lib/small-model/DOCUMENTATION.md +++ b/packages/web/server/lib/small-model/DOCUMENTATION.md @@ -69,10 +69,17 @@ other runtime API. - `timeoutMs` overrides the 60s default per call; `signal` lets a caller abort a request that is no longer wanted. Both apply to every wire format. - `describeSmallModel()` additionally reports `inputCharBudget`, - `contextTokens`, `contextKnown`, and `structuredOutput`. The last is - tri-state: `true`/`false` from the catalog, `null` when the catalog omits the - field — which it does for roughly half of all models, aggregators and proxies - especially. Callers must treat `null` as "try it", not "unsupported". + `contextTokens`, `contextKnown`, `structuredOutput`, and `hasLogin`. The last + is whether the resolved provider has a usable credential (`auth.json` or + config `provider..options.apiKey`) — settings/config overrides can name a + provider with none, and callers such as the walkthrough refuse before the + request. `structuredOutput` is tri-state: `true`/`false` from the catalog, + `null` when the catalog omits the field — which it does for roughly half of + all models, aggregators and proxies especially. Callers must treat `null` as + "try it", not "unsupported". +- Missing credentials throw with `statusCode: 401` and + `code: 'no-provider-login'` rather than a bare `Error`, so UI callers can show + a blocker instead of a raw 500 message. - `call.js` — wire formats and per-provider auth, replicating OpenCode's plugin auth loaders: - **GitHub Copilot**: fetches the requested model's authenticated `/models` diff --git a/packages/web/server/lib/small-model/call.js b/packages/web/server/lib/small-model/call.js index 17c8949c..c3a090a1 100644 --- a/packages/web/server/lib/small-model/call.js +++ b/packages/web/server/lib/small-model/call.js @@ -566,15 +566,31 @@ const readProviderConfig = (workingDirectory, providerID) => { // Dispatch // --------------------------------------------------------------------------- +/** + * Same credential resolution the request path uses: config + * `provider..options.apiKey` wins, then the auth.json entry. + * Callers that need to refuse before spending a request (walkthrough readiness) + * must use this rather than inventing a second rule. + */ +export function resolveProviderLogin({ auth, workingDirectory, providerID }) { + const providerConfig = readProviderConfig(workingDirectory, providerID); + return providerConfig?.auth || getAuthEntryForProvider(auth, providerID) || null; +} + export async function callSmallModel({ auth, catalog, workingDirectory, providerID, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) { const tokens = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : DEFAULT_MAX_OUTPUT_TOKENS; const providerConfig = readProviderConfig(workingDirectory, providerID); // Match OpenCode's resolveSDK precedence: - // config provider..options.apiKey (providerConfig.auth) wins; the - // auth.json entry is only a fallback. + // config provider..options.apiKey wins; the auth.json entry is only a fallback. const entry = providerConfig?.auth || getAuthEntryForProvider(auth, providerID); if (!entry) { - throw new Error(`No OpenCode login found for provider "${providerID}"`); + // Structured so the walkthrough (and any other caller) can show a blocker + // instead of a raw 500 banner with this developer-oriented sentence. + throw Object.assign(new Error(`No OpenCode login found for provider "${providerID}"`), { + statusCode: 401, + code: 'no-provider-login', + providerID, + }); } if (providerID === 'github-copilot') { diff --git a/packages/web/server/lib/small-model/call.test.js b/packages/web/server/lib/small-model/call.test.js index 78ef733f..154fd986 100644 --- a/packages/web/server/lib/small-model/call.test.js +++ b/packages/web/server/lib/small-model/call.test.js @@ -171,14 +171,21 @@ describe('callSmallModel — custom provider config', () => { provider: { custom: { options: { baseURL: 'https://proxy.example.test/v1' } } }, }); - await expect(callSmallModel({ + const error = await callSmallModel({ auth: {}, catalog: {}, workingDirectory: '/proj', providerID: 'custom', modelID: 'gpt-4o-mini', prompt: 'hi', - })).rejects.toThrow('No OpenCode login found for provider "custom"'); + }).then(() => null, (e) => e); + + expect(error).toMatchObject({ + message: 'No OpenCode login found for provider "custom"', + code: 'no-provider-login', + statusCode: 401, + providerID: 'custom', + }); // The credential gate fires before any network call. expect(fetchMock).not.toHaveBeenCalled(); diff --git a/packages/web/server/lib/small-model/index.js b/packages/web/server/lib/small-model/index.js index 40e5e7f5..955797e6 100644 --- a/packages/web/server/lib/small-model/index.js +++ b/packages/web/server/lib/small-model/index.js @@ -5,7 +5,7 @@ import { readAuthFile } from '../opencode/auth.js'; import { readConfigLayers } from '../opencode/shared.js'; import { getModelCatalog } from './catalog.js'; import { resolveSmallModel, parseModelRef, isUsableAuthEntry, getAuthEntryForProvider } from './resolve.js'; -import { callSmallModel } from './call.js'; +import { callSmallModel, resolveProviderLogin } from './call.js'; const OPENCHAMBER_SETTINGS_FILE = path.join( process.env.OPENCHAMBER_DATA_DIR @@ -252,8 +252,17 @@ export async function describeSmallModel({ directory, preferredProviderID, prefe outputReserveTokens: reserveTokens, }); + // Settings/config/request overrides can name a provider with no usable login. + // Report that here so readiness can refuse before the user pays for a 401. + const hasLogin = Boolean(resolveProviderLogin({ + auth, + workingDirectory: directory, + providerID: resolved.providerID, + })); + return { ...resolved, + hasLogin, inputCharBudget: maxChars, contextTokens, contextKnown, diff --git a/packages/web/server/lib/small-model/index.test.js b/packages/web/server/lib/small-model/index.test.js index 14e5f015..fc5d741c 100644 --- a/packages/web/server/lib/small-model/index.test.js +++ b/packages/web/server/lib/small-model/index.test.js @@ -18,7 +18,13 @@ vi.mock('./catalog.js', () => ({ getModelCatalog: vi.fn(), getCatalogProvider: vi.fn(), })); -vi.mock('./call.js', () => ({ callSmallModel: vi.fn() })); +vi.mock('./call.js', () => ({ + callSmallModel: vi.fn(), + resolveProviderLogin: vi.fn(({ auth, providerID }) => { + const entry = auth?.[providerID]; + return entry && typeof entry === 'object' ? entry : null; + }), +})); const { generateSmallModelText, describeSmallModel } = await import('./index.js'); const { readAuthFile } = await import('../opencode/auth.js'); @@ -126,6 +132,19 @@ describe('describeSmallModel — capability reporting', () => { contextTokens: 8_000, contextKnown: true, structuredOutput: true, + hasLogin: true, + }); + }); + + it('reports hasLogin false when the resolved provider has no usable credential', async () => { + readAuthFile.mockReturnValue({}); + + const described = await describeSmallModel({ directory: '/proj' }); + + expect(described).toMatchObject({ + providerID: 'anthropic', + modelID: 'claude-haiku-4-5', + hasLogin: false, }); }); diff --git a/packages/web/server/lib/walkthrough/DOCUMENTATION.md b/packages/web/server/lib/walkthrough/DOCUMENTATION.md index 43d59d16..63997184 100644 --- a/packages/web/server/lib/walkthrough/DOCUMENTATION.md +++ b/packages/web/server/lib/walkthrough/DOCUMENTATION.md @@ -118,6 +118,13 @@ model picker, only shows providers with a usable login. The in-panel picker on a blocked walkthrough writes this setting too, so recovering from a refusal never silently changes the model behind commit messages. +A settings or `opencode.json` `small_model` override can still name a provider +with no usable login (neither `auth.json` nor `provider..options.apiKey`). +`describeSmallModel` reports that as `hasLogin: false`, readiness refuses with +`code: 'no-provider-login'`, and generation maps the same code to HTTP 401 — +so the panel shows a blocker with a model picker instead of looking ready and +then dumping the raw `No OpenCode login found for provider "…"` string. + ## Output language A walkthrough its reader cannot read is worth nothing, so the prose language is diff --git a/packages/web/server/lib/walkthrough/index.js b/packages/web/server/lib/walkthrough/index.js index 600b8af4..057a2c58 100644 --- a/packages/web/server/lib/walkthrough/index.js +++ b/packages/web/server/lib/walkthrough/index.js @@ -333,6 +333,12 @@ function computeReadiness({ model, digest, files, fileCount, hunkCount, generate return { ready: false, reason, model, generatedFileCount }; } + // A resolved override/config model can still have no usable login. Refuse up + // front so the panel does not look ready and then dump a raw auth error. + if (model.hasLogin === false) { + return { ready: false, reason: 'no-provider-login', model }; + } + // Built with the same language the generation would use: the instruction is // part of the prompt, so a readiness answer computed without it would be // measuring a request nobody is going to send. @@ -392,6 +398,13 @@ async function runGeneration({ directory, source, repoRoot, key, force, explicit if (!model) { throw fail('No model is available — sign in to a provider first', 404, { code: 'no-model' }); } + if (model.hasLogin === false) { + throw fail( + `No OpenCode login found for provider "${model.providerID}" — sign in or choose a different model`, + 401, + { code: 'no-provider-login', model }, + ); + } const { digest, files, idByAlias, fileCount, hunkCount, generatedFileCount } = await loadCurrentDiff(directory, source, deps); setStage(repoRoot, key, 'asking'); @@ -494,6 +507,9 @@ async function runGeneration({ directory, source, repoRoot, key, force, explicit if (error?.code === 'output-exhausted') { return fail(error.message, 409, { code: 'output-exhausted', model }); } + if (error?.code === 'no-provider-login') { + return fail(error.message, 401, { code: 'no-provider-login', model }); + } return null; }; diff --git a/packages/web/server/lib/walkthrough/reproduce-2607.test.js b/packages/web/server/lib/walkthrough/reproduce-2607.test.js new file mode 100644 index 00000000..7ef6c15c --- /dev/null +++ b/packages/web/server/lib/walkthrough/reproduce-2607.test.js @@ -0,0 +1,151 @@ +import { execFileSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import express from 'express'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +// --------------------------------------------------------------------------- +// Regression for https://github.com/openchamber/openchamber/issues/2607 +// "[Bug] Why say so?" (walkthrough panel) +// +// Before the fix, a walkthrough small model whose provider had no usable login +// reported readiness ready:true, then generation returned HTTP 500 with the raw +// message `No OpenCode login found for provider "deepseek"` — shown in the +// error banner above the "No walkthrough yet" empty state. +// +// After the fix: readiness refuses with `no-provider-login`, and generation +// answers 401 with the same structured code so the UI can show a blocker. +// --------------------------------------------------------------------------- + +const TEMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-home-2607-')); +process.env.HOME = TEMP_HOME; +process.env.OPENCHAMBER_DATA_DIR = path.join(TEMP_HOME, '.config', 'openchamber'); + +const CATALOG = { + deepseek: { + id: 'deepseek', + name: 'DeepSeek', + api: 'https://api.deepseek.com', + models: { + 'deepseek-v4-flash': { + id: 'deepseek-v4-flash', + name: 'DeepSeek V4 Flash', + family: 'deepseek-flash', + limit: { context: 128_000 }, + }, + }, + }, +}; + +vi.mock('../../opencode/models-metadata.js', () => ({ + getModelsMetadata: vi.fn(async () => ({ metadata: CATALOG, fromCache: false })), +})); + +const SOURCE = { kind: 'working-tree', scope: 'all' }; +const REPO_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-repo-2607-')); + +const setupGitRepo = () => { + const run = (args) => { + try { + return execFileSync('git', args, { cwd: REPO_DIR, encoding: 'utf8' }); + } catch (error) { + throw new Error(`git ${args.join(' ')} failed: ${error.stderr?.toString() ?? error.message}`); + } + }; + + run(['init', '-b', 'main']); + run(['config', 'user.email', 'test@example.com']); + run(['config', 'user.name', 'Test']); + fs.mkdirSync(path.join(REPO_DIR, 'src'), { recursive: true }); + fs.writeFileSync(path.join(REPO_DIR, 'src', 'a.ts'), 'export const a = 1;\n', 'utf8'); + run(['add', 'src/a.ts']); + run(['commit', '-m', 'init']); + fs.writeFileSync(path.join(REPO_DIR, 'src', 'a.ts'), 'export const a = 1;\nexport const b = 2;\n', 'utf8'); +}; + +let walkthrough; +let callSmallModel; + +describe('issue 2607 — walkthrough blocks unauthenticated providers', () => { + beforeAll(async () => { + setupGitRepo(); + fs.writeFileSync( + path.join(REPO_DIR, 'opencode.json'), + JSON.stringify({ small_model: 'deepseek/deepseek-v4-flash' }, null, 2), + 'utf8', + ); + + walkthrough = await import('./index.js'); + callSmallModel = await import('../small-model/call.js'); + }); + + afterAll(() => { + fs.rmSync(TEMP_HOME, { recursive: true, force: true }); + fs.rmSync(REPO_DIR, { recursive: true, force: true }); + }); + + it('resolves the deepseek model but reports not ready without a login', async () => { + const result = await walkthrough.getWalkthrough({ directory: REPO_DIR, source: SOURCE }); + + expect(result.readiness.ready).toBe(false); + expect(result.readiness.reason).toBe('no-provider-login'); + expect(result.readiness.model).toMatchObject({ + providerID: 'deepseek', + modelID: 'deepseek-v4-flash', + hasLogin: false, + }); + }); + + it('callSmallModel throws a structured no-provider-login error', async () => { + const error = await callSmallModel.callSmallModel({ + auth: {}, + catalog: CATALOG, + workingDirectory: REPO_DIR, + providerID: 'deepseek', + modelID: 'deepseek-v4-flash', + prompt: 'x', + }).then(() => null, (e) => e); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe('No OpenCode login found for provider "deepseek"'); + expect(error.code).toBe('no-provider-login'); + expect(error.statusCode).toBe(401); + }); + + it('generateWalkthrough rejects with structured no-provider-login', async () => { + const error = await walkthrough.generateWalkthrough({ directory: REPO_DIR, source: SOURCE }) + .then(() => null, (e) => e); + + expect(error).toBeInstanceOf(Error); + expect(error.code).toBe('no-provider-login'); + expect(error.statusCode).toBe(401); + expect(error.model).toMatchObject({ providerID: 'deepseek', modelID: 'deepseek-v4-flash' }); + }); + + it('answers the generate route with HTTP 401 and code no-provider-login', async () => { + const service = { ...walkthrough, getPullRequestDiff: async () => { throw new Error('not used'); } }; + const app = express(); + app.use(express.json()); + const { registerWalkthroughRoutes } = await import('./routes.js'); + registerWalkthroughRoutes(app, { getWalkthroughService: async () => service }); + + const server = app.listen(0); + await new Promise((resolve) => server.once('listening', resolve)); + const base = `http://127.0.0.1:${server.address().port}`; + try { + const response = await fetch(`${base}/api/walkthrough/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ directory: REPO_DIR, source: SOURCE }), + }); + const body = await response.json(); + + expect(response.status).toBe(401); + expect(body.code).toBe('no-provider-login'); + expect(body.model).toMatchObject({ providerID: 'deepseek', modelID: 'deepseek-v4-flash' }); + } finally { + await new Promise((resolve) => server.close(resolve)); + } + }); +}); From 35f17e9e96be0ca5ef6a940bf7c95d45859ea57f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 4 Aug 2026 12:22:54 +0000 Subject: [PATCH 2/4] fix(walkthrough): hide unauthenticated models and disable Generate Do not present a provider without a login as the selected walkthrough model, and grey out Generate when readiness is false instead of showing a login-error blocker or raw auth banner. Co-authored-by: Serhii Dziupin --- .../views/walkthrough/WalkthroughBlocker.tsx | 9 +--- .../views/walkthrough/WalkthroughView.tsx | 46 +++++++++++++++---- packages/ui/src/lib/i18n/messages/de.ts | 3 -- packages/ui/src/lib/i18n/messages/en.ts | 3 -- packages/ui/src/lib/i18n/messages/es.ts | 3 -- packages/ui/src/lib/i18n/messages/fr.ts | 3 -- packages/ui/src/lib/i18n/messages/ja.ts | 3 -- packages/ui/src/lib/i18n/messages/ko.ts | 3 -- packages/ui/src/lib/i18n/messages/pl.ts | 3 -- packages/ui/src/lib/i18n/messages/pt-BR.ts | 3 -- packages/ui/src/lib/i18n/messages/uk.ts | 3 -- packages/ui/src/lib/i18n/messages/zh-CN.ts | 3 -- packages/ui/src/lib/i18n/messages/zh-TW.ts | 3 -- packages/ui/src/lib/walkthrough/types.ts | 2 + .../server/lib/walkthrough/DOCUMENTATION.md | 7 +-- packages/web/server/lib/walkthrough/index.js | 5 +- .../lib/walkthrough/reproduce-2607.test.js | 7 +-- 17 files changed, 49 insertions(+), 60 deletions(-) diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx index f23696be..2f0af021 100644 --- a/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx +++ b/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx @@ -41,8 +41,7 @@ export const WalkthroughBlocker = ({ // Settings. const canChooseModel = reason === 'context-too-small' || reason === 'structured-output-unsupported' - || reason === 'output-exhausted' - || reason === 'no-provider-login'; + || reason === 'output-exhausted'; useEffect(() => { if (!canChooseModel || providers !== undefined) return; @@ -101,11 +100,6 @@ export const WalkthroughBlocker = ({ const description = () => { if (reason === 'no-model') return t('walkthrough.blocked.noModel.description'); - if (reason === 'no-provider-login') { - return label - ? t('walkthrough.blocked.noProviderLogin.description', { model: label }) - : t('walkthrough.blocked.noProviderLogin.descriptionUnknownModel'); - } if (reason === 'empty-diff') return t('walkthrough.blocked.emptyDiff.description'); if (reason === 'only-generated') return t('walkthrough.blocked.onlyGenerated.description'); if (reason === 'output-exhausted') { @@ -129,7 +123,6 @@ export const WalkthroughBlocker = ({ const title = () => { if (reason === 'no-model') return t('walkthrough.blocked.noModel.title'); - if (reason === 'no-provider-login') return t('walkthrough.blocked.noProviderLogin.title'); if (reason === 'empty-diff') return t('walkthrough.blocked.emptyDiff.title'); if (reason === 'only-generated') return t('walkthrough.blocked.onlyGenerated.title'); if (reason === 'output-exhausted') return t('walkthrough.blocked.outputExhausted.title'); diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx index 15b9baa8..2c53641d 100644 --- a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx +++ b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx @@ -323,15 +323,34 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { // Explicit pick first, then the model that actually produced what is on // screen, then whatever settings resolve to. The middle step is what makes // reopening a review show the model behind it rather than the default. - const activeModel = selectedModel - ?? (entry.result?.model ? `${entry.result.model.providerID}/${entry.result.model.modelID}` : undefined) - ?? (entry.readiness?.model ? `${entry.readiness.model.providerID}/${entry.readiness.model.modelID}` : undefined); - const [activeProviderId, ...activeModelParts] = (activeModel ?? '').split('/'); - const activeModelId = activeModelParts.join('/'); - + // Never present a provider without a usable login as the current selection — + // the picker already hides them from the menu; showing one as selected was + // the whole "why say so?" failure mode. const modelsMetadata = useConfigStore((state) => state.modelsMetadata); const [modelProviders, setModelProviders] = useState(undefined); + const providerIsAuthenticated = (providerId: string | undefined) => { + if (!providerId) return false; + if (modelProviders === undefined) return true; + return modelProviders.includes(providerId); + }; + const readinessModelRef = entry.readiness?.model + && entry.readiness.model.hasLogin !== false + && providerIsAuthenticated(entry.readiness.model.providerID) + ? `${entry.readiness.model.providerID}/${entry.readiness.model.modelID}` + : undefined; + const resultModelRef = entry.result?.model + && providerIsAuthenticated(entry.result.model.providerID) + ? `${entry.result.model.providerID}/${entry.result.model.modelID}` + : undefined; + const selectedModelUsable = selectedModel + && providerIsAuthenticated(selectedModel.split('/')[0]) + ? selectedModel + : undefined; + const activeModel = selectedModelUsable ?? resultModelRef ?? readinessModelRef; + const [activeProviderId, ...activeModelParts] = (activeModel ?? '').split('/'); + const activeModelId = activeModelParts.join('/'); + useEffect(() => { if (modelProviders !== undefined) return; let cancelled = false; @@ -403,15 +422,17 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { const showStages = startedFromEmptyRef.current && (entry.status === 'generating' || stageProgress.holding); + // Auth/login gaps are not a full-panel blocker: hide the unusable model and + // disable Generate instead of explaining a raw provider error. const blockedReason = entry.error?.code === 'context-too-small' || entry.error?.code === 'structured-output-unsupported' || entry.error?.code === 'no-model' - || entry.error?.code === 'no-provider-login' || entry.error?.code === 'empty-diff' || entry.error?.code === 'only-generated' || entry.error?.code === 'output-exhausted' ? entry.error.code : entry.readiness && !entry.readiness.ready && !view + && entry.readiness.reason !== 'no-provider-login' ? entry.readiness.reason : undefined; @@ -421,11 +442,16 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { const blockedRequiredChars = entry.error?.requiredChars ?? entry.readiness?.requiredChars; const blockedAvailableChars = entry.error?.availableChars ?? entry.readiness?.availableChars; + // Not ready means Generate must not look actionable — including when the + // resolved model has no login (reason no-provider-login). + const generateDisabled = Boolean(entry.readiness && !entry.readiness.ready); + const handleGenerate = useCallback( (force: boolean) => { + if (generateDisabled) return; void generate(directory, source, { force, language: activeLanguage }); }, - [activeLanguage, directory, generate, source] + [activeLanguage, directory, generate, generateDisabled, source] ); return ( @@ -594,6 +620,7 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { variant="outline" size="sm" className={WALKTHROUGH_ACTION_CLASS} + disabled={generateDisabled} aria-label={compactHeader ? (view ? t('walkthrough.action.regenerate') : t('walkthrough.action.generate')) : undefined} @@ -647,6 +674,7 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { variant="ghost" size="xs" className="ml-auto" + disabled={generateDisabled} // Not forced: if an entry for this exact request existed the banner // would not be here, and a forced run would refuse the cache it may // find on the way. @@ -678,7 +706,7 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { )} - {entry.error && !blockedReason && ( + {entry.error && !blockedReason && entry.error.code !== 'no-provider-login' && (
{/* Provider errors arrive as raw JSON bodies. Show a readable amount diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 4f353026..0db76c86 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -2866,9 +2866,6 @@ export const dict = { 'walkthrough.importance.context': 'Kontext', 'walkthrough.blocked.noModel.title': 'Kein Modell ausgewählt', 'walkthrough.blocked.noModel.description': 'Wählen Sie zuerst ein Modell aus.', - 'walkthrough.blocked.noProviderLogin.title': 'Dieser Anbieter ist nicht angemeldet', - 'walkthrough.blocked.noProviderLogin.description': '{model} braucht eine Anmeldung bei seinem Anbieter. Melde dich an oder wähle ein Modell eines Anbieters, den du bereits nutzt.', - 'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': 'Das ausgewählte Modell braucht eine Anmeldung bei seinem Anbieter. Melde dich an oder wähle ein Modell eines Anbieters, den du bereits nutzt.', 'walkthrough.blocked.emptyDiff.title': 'Kein Diff vorhanden', 'walkthrough.blocked.emptyDiff.description': 'Es gibt keine Änderungen, die zusammengefasst werden können.', 'walkthrough.blocked.contextTooSmall.title': 'Kontext zu klein', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 53929a2f..17d137e5 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1147,9 +1147,6 @@ export const dict = { 'walkthrough.importance.context': 'Context', 'walkthrough.blocked.noModel.title': 'No small model available', 'walkthrough.blocked.noModel.description': 'Sign in to a model provider to generate a review.', - 'walkthrough.blocked.noProviderLogin.title': 'This provider is not signed in', - 'walkthrough.blocked.noProviderLogin.description': '{model} needs a login for its provider. Sign in, or choose a model from a provider you already use.', - 'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': 'The selected model needs a login for its provider. Sign in, or choose a model from a provider you already use.', 'walkthrough.blocked.emptyDiff.title': 'Nothing to review', 'walkthrough.blocked.emptyDiff.description': 'There are no changes in this scope yet.', 'walkthrough.blocked.contextTooSmall.title': 'This diff is too large for the current model', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 140e686e..ea5086d0 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1148,9 +1148,6 @@ export const dict: Record = { "walkthrough.importance.context": "Contexto", "walkthrough.blocked.noModel.title": "No hay ningún modelo pequeño disponible", "walkthrough.blocked.noModel.description": "Inicia sesión en un proveedor de modelos para generar una revisión.", - "walkthrough.blocked.noProviderLogin.title": "Este proveedor no tiene sesión iniciada", - "walkthrough.blocked.noProviderLogin.description": "{model} necesita un inicio de sesión en su proveedor. Inicia sesión o elige un modelo de un proveedor que ya uses.", - "walkthrough.blocked.noProviderLogin.descriptionUnknownModel": "El modelo seleccionado necesita un inicio de sesión en su proveedor. Inicia sesión o elige un modelo de un proveedor que ya uses.", "walkthrough.blocked.emptyDiff.title": "Nada que revisar", "walkthrough.blocked.emptyDiff.description": "Todavía no hay cambios en este ámbito.", "walkthrough.blocked.contextTooSmall.title": "Este diff es demasiado grande para el modelo actual", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 479033dc..3a625450 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -972,9 +972,6 @@ export const dict = { 'walkthrough.importance.context': 'Contexte', 'walkthrough.blocked.noModel.title': 'Aucun petit modèle disponible', 'walkthrough.blocked.noModel.description': 'Connectez-vous à un fournisseur de modèles pour générer une revue.', - 'walkthrough.blocked.noProviderLogin.title': 'Ce fournisseur n’est pas connecté', - 'walkthrough.blocked.noProviderLogin.description': '{model} nécessite une connexion à son fournisseur. Connectez-vous, ou choisissez un modèle d’un fournisseur que vous utilisez déjà.', - 'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': 'Le modèle sélectionné nécessite une connexion à son fournisseur. Connectez-vous, ou choisissez un modèle d’un fournisseur que vous utilisez déjà.', 'walkthrough.blocked.emptyDiff.title': 'Rien à examiner', 'walkthrough.blocked.emptyDiff.description': 'Il n’y a encore aucune modification dans cette portée.', 'walkthrough.blocked.contextTooSmall.title': 'Ce diff est trop volumineux pour le modèle actuel', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index ed810851..181ec3e9 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1144,9 +1144,6 @@ export const dict: Record = { 'walkthrough.importance.context': '補足', 'walkthrough.blocked.noModel.title': '利用できるスモールモデルがありません', 'walkthrough.blocked.noModel.description': 'レビューを生成するにはモデルプロバイダーにサインインしてください。', - 'walkthrough.blocked.noProviderLogin.title': 'このプロバイダーにはサインインしていません', - 'walkthrough.blocked.noProviderLogin.description': '{model} にはプロバイダーへのログインが必要です。サインインするか、すでに使っているプロバイダーのモデルを選んでください。', - 'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': '選択したモデルにはプロバイダーへのログインが必要です。サインインするか、すでに使っているプロバイダーのモデルを選んでください。', 'walkthrough.blocked.emptyDiff.title': 'レビュー対象がありません', 'walkthrough.blocked.emptyDiff.description': 'この範囲にはまだ変更がありません。', 'walkthrough.blocked.contextTooSmall.title': 'この差分は現在のモデルには大きすぎます', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index d9d68816..150a56ce 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1148,9 +1148,6 @@ export const dict: Record = { 'walkthrough.importance.context': '참고', 'walkthrough.blocked.noModel.title': '사용할 수 있는 스몰 모델이 없습니다', 'walkthrough.blocked.noModel.description': '리뷰를 생성하려면 모델 제공자에 로그인하세요.', - 'walkthrough.blocked.noProviderLogin.title': '이 제공자에 로그인되어 있지 않습니다', - 'walkthrough.blocked.noProviderLogin.description': '{model}을(를) 쓰려면 해당 제공자에 로그인해야 합니다. 로그인하거나, 이미 사용 중인 제공자의 모델을 선택하세요.', - 'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': '선택한 모델을 쓰려면 해당 제공자에 로그인해야 합니다. 로그인하거나, 이미 사용 중인 제공자의 모델을 선택하세요.', 'walkthrough.blocked.emptyDiff.title': '리뷰할 내용이 없습니다', 'walkthrough.blocked.emptyDiff.description': '이 범위에는 아직 변경 사항이 없습니다.', 'walkthrough.blocked.contextTooSmall.title': '이 diff는 현재 모델에 너무 큽니다', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 949b745f..8f93e1f6 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1460,9 +1460,6 @@ export const dict: Record = { 'walkthrough.importance.context': 'Kontekst', 'walkthrough.blocked.noModel.title': 'Brak dostępnego małego modelu', 'walkthrough.blocked.noModel.description': 'Zaloguj się u dostawcy modeli, aby wygenerować przegląd.', - 'walkthrough.blocked.noProviderLogin.title': 'Ten dostawca nie jest zalogowany', - 'walkthrough.blocked.noProviderLogin.description': '{model} wymaga logowania u swojego dostawcy. Zaloguj się albo wybierz model u dostawcy, którego już używasz.', - 'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': 'Wybrany model wymaga logowania u swojego dostawcy. Zaloguj się albo wybierz model u dostawcy, którego już używasz.', 'walkthrough.blocked.emptyDiff.title': 'Nie ma czego przeglądać', 'walkthrough.blocked.emptyDiff.description': 'W tym zakresie nie ma jeszcze zmian.', 'walkthrough.blocked.contextTooSmall.title': 'Te różnice są za duże dla bieżącego modelu', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 6c79996a..44fef67a 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1148,9 +1148,6 @@ export const dict: Record = { "walkthrough.importance.context": "Contexto", "walkthrough.blocked.noModel.title": "Nenhum modelo pequeno disponível", "walkthrough.blocked.noModel.description": "Entre em um provedor de modelos para gerar uma revisão.", - "walkthrough.blocked.noProviderLogin.title": "Este provedor não está conectado", - "walkthrough.blocked.noProviderLogin.description": "{model} precisa de login no provedor. Entre na conta ou escolha um modelo de um provedor que você já usa.", - "walkthrough.blocked.noProviderLogin.descriptionUnknownModel": "O modelo selecionado precisa de login no provedor. Entre na conta ou escolha um modelo de um provedor que você já usa.", "walkthrough.blocked.emptyDiff.title": "Nada para revisar", "walkthrough.blocked.emptyDiff.description": "Ainda não há mudanças neste escopo.", "walkthrough.blocked.contextTooSmall.title": "Este diff é grande demais para o modelo atual", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 6005df66..c8663cea 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1148,9 +1148,6 @@ export const dict: Record = { "walkthrough.importance.context": "Контекст", "walkthrough.blocked.noModel.title": "Немає доступної small model", "walkthrough.blocked.noModel.description": "Увійдіть до провайдера моделей, щоб створити розбір.", - "walkthrough.blocked.noProviderLogin.title": "У цей провайдер не ввійшли", - "walkthrough.blocked.noProviderLogin.description": "{model} потребує входу в його провайдер. Увійдіть або виберіть модель у провайдера, яким ви вже користуєтесь.", - "walkthrough.blocked.noProviderLogin.descriptionUnknownModel": "Вибрана модель потребує входу в її провайдер. Увійдіть або виберіть модель у провайдера, яким ви вже користуєтесь.", "walkthrough.blocked.emptyDiff.title": "Немає що оглядати", "walkthrough.blocked.emptyDiff.description": "У цій області поки що немає змін.", "walkthrough.blocked.contextTooSmall.title": "Цей diff завеликий для поточної моделі", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 551b95cf..76ffac2f 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1148,9 +1148,6 @@ export const dict: Record = { 'walkthrough.importance.context': '背景', 'walkthrough.blocked.noModel.title': '没有可用的小模型', 'walkthrough.blocked.noModel.description': '请登录模型提供方后再生成评审。', - 'walkthrough.blocked.noProviderLogin.title': '尚未登录此提供方', - 'walkthrough.blocked.noProviderLogin.description': '{model} 需要登录其提供方。请先登录,或改选你已在使用的提供方中的模型。', - 'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': '所选模型需要登录其提供方。请先登录,或改选你已在使用的提供方中的模型。', 'walkthrough.blocked.emptyDiff.title': '没有可评审的内容', 'walkthrough.blocked.emptyDiff.description': '该范围内暂无改动。', 'walkthrough.blocked.contextTooSmall.title': '当前模型无法容纳这份差异', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 95b0af8b..3538d59c 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1160,9 +1160,6 @@ export const dict: Record = { 'walkthrough.importance.context': '背景', 'walkthrough.blocked.noModel.title': '沒有可用的小模型', 'walkthrough.blocked.noModel.description': '請先登入模型供應商再產生審閱。', - 'walkthrough.blocked.noProviderLogin.title': '尚未登入此供應商', - 'walkthrough.blocked.noProviderLogin.description': '{model} 需要登入其供應商。請先登入,或改選你已在使用的供應商中的模型。', - 'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': '所選模型需要登入其供應商。請先登入,或改選你已在使用的供應商中的模型。', 'walkthrough.blocked.emptyDiff.title': '沒有可審閱的內容', 'walkthrough.blocked.emptyDiff.description': '此範圍目前沒有變更。', 'walkthrough.blocked.contextTooSmall.title': '目前模型無法容納這份差異', diff --git a/packages/ui/src/lib/walkthrough/types.ts b/packages/ui/src/lib/walkthrough/types.ts index 40bfd62e..5f8ef80a 100644 --- a/packages/ui/src/lib/walkthrough/types.ts +++ b/packages/ui/src/lib/walkthrough/types.ts @@ -105,6 +105,8 @@ export interface WalkthroughReadiness { inputCharBudget?: number; contextTokens?: number; structuredOutput?: boolean | null; + /** False when the resolved provider has no usable OpenCode login. */ + hasLogin?: boolean; }; requiredChars?: number; availableChars?: number; diff --git a/packages/web/server/lib/walkthrough/DOCUMENTATION.md b/packages/web/server/lib/walkthrough/DOCUMENTATION.md index 63997184..0f23fac5 100644 --- a/packages/web/server/lib/walkthrough/DOCUMENTATION.md +++ b/packages/web/server/lib/walkthrough/DOCUMENTATION.md @@ -121,9 +121,10 @@ silently changes the model behind commit messages. A settings or `opencode.json` `small_model` override can still name a provider with no usable login (neither `auth.json` nor `provider..options.apiKey`). `describeSmallModel` reports that as `hasLogin: false`, readiness refuses with -`code: 'no-provider-login'`, and generation maps the same code to HTTP 401 — -so the panel shows a blocker with a model picker instead of looking ready and -then dumping the raw `No OpenCode login found for provider "…"` string. +`reason: 'no-provider-login'` and omits the unusable model so the panel cannot +present it as selected, and generation maps the same code to HTTP 401. The UI +disables Generate and keeps the picker on authenticated providers only — it does +not surface a raw auth error or a special login blocker for this case. ## Output language diff --git a/packages/web/server/lib/walkthrough/index.js b/packages/web/server/lib/walkthrough/index.js index 057a2c58..f65d3e2f 100644 --- a/packages/web/server/lib/walkthrough/index.js +++ b/packages/web/server/lib/walkthrough/index.js @@ -334,9 +334,10 @@ function computeReadiness({ model, digest, files, fileCount, hunkCount, generate } // A resolved override/config model can still have no usable login. Refuse up - // front so the panel does not look ready and then dump a raw auth error. + // front and omit the model — offering an unauthenticated selection in the + // picker is what made the old raw auth error feel like a product bug. if (model.hasLogin === false) { - return { ready: false, reason: 'no-provider-login', model }; + return { ready: false, reason: 'no-provider-login' }; } // Built with the same language the generation would use: the instruction is diff --git a/packages/web/server/lib/walkthrough/reproduce-2607.test.js b/packages/web/server/lib/walkthrough/reproduce-2607.test.js index 7ef6c15c..8cca6e55 100644 --- a/packages/web/server/lib/walkthrough/reproduce-2607.test.js +++ b/packages/web/server/lib/walkthrough/reproduce-2607.test.js @@ -90,11 +90,8 @@ describe('issue 2607 — walkthrough blocks unauthenticated providers', () => { expect(result.readiness.ready).toBe(false); expect(result.readiness.reason).toBe('no-provider-login'); - expect(result.readiness.model).toMatchObject({ - providerID: 'deepseek', - modelID: 'deepseek-v4-flash', - hasLogin: false, - }); + // Unusable models must not be offered as the current selection. + expect(result.readiness.model).toBeUndefined(); }); it('callSmallModel throws a structured no-provider-login error', async () => { From 5c6eee331ea6f8018f8b2b7d14b80c006d599a01 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 4 Aug 2026 12:35:25 +0000 Subject: [PATCH 3/4] fix(walkthrough): keep unauthenticated models out of the picker Treat an empty allowedProviderIds list as allow-none, disable Generate when no usable model is selected, and mute the button styling so it reads as unavailable rather than actionable. Co-authored-by: Serhii Dziupin --- .../model-picker/ModelPickerList.tsx | 5 ++++- .../views/walkthrough/WalkthroughBlocker.tsx | 2 +- .../views/walkthrough/WalkthroughView.tsx | 18 ++++++++++++------ 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/components/model-picker/ModelPickerList.tsx b/packages/ui/src/components/model-picker/ModelPickerList.tsx index f06dc190..99080f9c 100644 --- a/packages/ui/src/components/model-picker/ModelPickerList.tsx +++ b/packages/ui/src/components/model-picker/ModelPickerList.tsx @@ -436,7 +436,10 @@ export const ModelPickerList: React.FC = ({ ); const allowedProviderSet = React.useMemo(() => { - if (!allowedProviderIds || allowedProviderIds.length === 0) return null; + // undefined = no restriction; [] = allow none. Treating empty like + // "unrestricted" would resurface providers without a login in pickers that + // intentionally pass the authenticated-only list. + if (!allowedProviderIds) return null; return new Set(allowedProviderIds); }, [allowedProviderIds]); diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx index 2f0af021..a9a620ac 100644 --- a/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx +++ b/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx @@ -150,7 +150,7 @@ export const WalkthroughBlocker = ({ onChange={(providerId, modelId) => { void handleModelChange(providerId, modelId); }} - allowedProviderIds={providers} + allowedProviderIds={providers ?? []} isModelAllowed={isStructuredOutputCapable} />
diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx index 2c53641d..25a19097 100644 --- a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx +++ b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx @@ -331,7 +331,9 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { const providerIsAuthenticated = (providerId: string | undefined) => { if (!providerId) return false; - if (modelProviders === undefined) return true; + // Until the auth list loads, do not present a candidate as selected — + // otherwise an unauthenticated config model flashes in the picker. + if (modelProviders === undefined) return false; return modelProviders.includes(providerId); }; const readinessModelRef = entry.readiness?.model @@ -442,9 +444,9 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { const blockedRequiredChars = entry.error?.requiredChars ?? entry.readiness?.requiredChars; const blockedAvailableChars = entry.error?.availableChars ?? entry.readiness?.availableChars; - // Not ready means Generate must not look actionable — including when the - // resolved model has no login (reason no-provider-login). - const generateDisabled = Boolean(entry.readiness && !entry.readiness.ready); + // Not ready, or no usable selected model, means Generate must not look + // actionable — including when the resolved model has no login. + const generateDisabled = !activeModel || Boolean(entry.readiness && !entry.readiness.ready); const handleGenerate = useCallback( (force: boolean) => { @@ -571,7 +573,8 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { onChange={(providerId, modelId) => { selectModel(directory, source, providerId && modelId ? `${providerId}/${modelId}` : null); }} - allowedProviderIds={modelProviders} + // While the auth list is loading, allow none — not every provider. + allowedProviderIds={modelProviders ?? []} isModelAllowed={isStructuredOutputCapable} tooltipsEnabled={false} dropdownPortalToBody @@ -619,7 +622,10 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { type="button" variant="outline" size="sm" - className={WALKTHROUGH_ACTION_CLASS} + className={cn( + WALKTHROUGH_ACTION_CLASS, + generateDisabled && 'border-border bg-transparent text-muted-foreground hover:bg-transparent hover:text-muted-foreground', + )} disabled={generateDisabled} aria-label={compactHeader ? (view ? t('walkthrough.action.regenerate') : t('walkthrough.action.generate')) From bc24b8a8366f2c7ab1d84b36177fba48f47a075a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 4 Aug 2026 12:43:31 +0000 Subject: [PATCH 4/4] fix(walkthrough): drop info tint from disabled Generate button The status-info classes were winning over muted disabled styles, so the button still looked actionable when no model was selected. Co-authored-by: Serhii Dziupin --- .../src/components/views/walkthrough/WalkthroughView.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx index 25a19097..246a818c 100644 --- a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx +++ b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx @@ -622,10 +622,9 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { type="button" variant="outline" size="sm" - className={cn( - WALKTHROUGH_ACTION_CLASS, - generateDisabled && 'border-border bg-transparent text-muted-foreground hover:bg-transparent hover:text-muted-foreground', - )} + className={generateDisabled + ? 'border-border text-muted-foreground' + : WALKTHROUGH_ACTION_CLASS} disabled={generateDisabled} aria-label={compactHeader ? (view ? t('walkthrough.action.regenerate') : t('walkthrough.action.generate'))