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 <makeittech@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-08-04 11:29:58 +00:00
co-authored by Serhii Dziupin
parent f47110c66f
commit abb396e080
22 changed files with 286 additions and 12 deletions
@@ -41,7 +41,8 @@ export const WalkthroughBlocker = ({
// Settings. // Settings.
const canChooseModel = reason === 'context-too-small' const canChooseModel = reason === 'context-too-small'
|| reason === 'structured-output-unsupported' || reason === 'structured-output-unsupported'
|| reason === 'output-exhausted'; || reason === 'output-exhausted'
|| reason === 'no-provider-login';
useEffect(() => { useEffect(() => {
if (!canChooseModel || providers !== undefined) return; if (!canChooseModel || providers !== undefined) return;
@@ -100,6 +101,11 @@ export const WalkthroughBlocker = ({
const description = () => { const description = () => {
if (reason === 'no-model') return t('walkthrough.blocked.noModel.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 === 'empty-diff') return t('walkthrough.blocked.emptyDiff.description');
if (reason === 'only-generated') return t('walkthrough.blocked.onlyGenerated.description'); if (reason === 'only-generated') return t('walkthrough.blocked.onlyGenerated.description');
if (reason === 'output-exhausted') { if (reason === 'output-exhausted') {
@@ -123,6 +129,7 @@ export const WalkthroughBlocker = ({
const title = () => { const title = () => {
if (reason === 'no-model') return t('walkthrough.blocked.noModel.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 === 'empty-diff') return t('walkthrough.blocked.emptyDiff.title');
if (reason === 'only-generated') return t('walkthrough.blocked.onlyGenerated.title'); if (reason === 'only-generated') return t('walkthrough.blocked.onlyGenerated.title');
if (reason === 'output-exhausted') return t('walkthrough.blocked.outputExhausted.title'); if (reason === 'output-exhausted') return t('walkthrough.blocked.outputExhausted.title');
@@ -406,6 +406,7 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
const blockedReason = entry.error?.code === 'context-too-small' const blockedReason = entry.error?.code === 'context-too-small'
|| entry.error?.code === 'structured-output-unsupported' || entry.error?.code === 'structured-output-unsupported'
|| entry.error?.code === 'no-model' || entry.error?.code === 'no-model'
|| entry.error?.code === 'no-provider-login'
|| entry.error?.code === 'empty-diff' || entry.error?.code === 'empty-diff'
|| entry.error?.code === 'only-generated' || entry.error?.code === 'only-generated'
|| entry.error?.code === 'output-exhausted' || entry.error?.code === 'output-exhausted'
+3
View File
@@ -2866,6 +2866,9 @@ export const dict = {
'walkthrough.importance.context': 'Kontext', 'walkthrough.importance.context': 'Kontext',
'walkthrough.blocked.noModel.title': 'Kein Modell ausgewählt', 'walkthrough.blocked.noModel.title': 'Kein Modell ausgewählt',
'walkthrough.blocked.noModel.description': 'Wählen Sie zuerst ein Modell aus.', '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.title': 'Kein Diff vorhanden',
'walkthrough.blocked.emptyDiff.description': 'Es gibt keine Änderungen, die zusammengefasst werden können.', 'walkthrough.blocked.emptyDiff.description': 'Es gibt keine Änderungen, die zusammengefasst werden können.',
'walkthrough.blocked.contextTooSmall.title': 'Kontext zu klein', 'walkthrough.blocked.contextTooSmall.title': 'Kontext zu klein',
+3
View File
@@ -1147,6 +1147,9 @@ export const dict = {
'walkthrough.importance.context': 'Context', 'walkthrough.importance.context': 'Context',
'walkthrough.blocked.noModel.title': 'No small model available', 'walkthrough.blocked.noModel.title': 'No small model available',
'walkthrough.blocked.noModel.description': 'Sign in to a model provider to generate a review.', '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.title': 'Nothing to review',
'walkthrough.blocked.emptyDiff.description': 'There are no changes in this scope yet.', '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', 'walkthrough.blocked.contextTooSmall.title': 'This diff is too large for the current model',
+3
View File
@@ -1148,6 +1148,9 @@ export const dict: Record<I18nKey, string> = {
"walkthrough.importance.context": "Contexto", "walkthrough.importance.context": "Contexto",
"walkthrough.blocked.noModel.title": "No hay ningún modelo pequeño disponible", "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.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.title": "Nada que revisar",
"walkthrough.blocked.emptyDiff.description": "Todavía no hay cambios en este ámbito.", "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", "walkthrough.blocked.contextTooSmall.title": "Este diff es demasiado grande para el modelo actual",
+3
View File
@@ -972,6 +972,9 @@ export const dict = {
'walkthrough.importance.context': 'Contexte', 'walkthrough.importance.context': 'Contexte',
'walkthrough.blocked.noModel.title': 'Aucun petit modèle disponible', '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.noModel.description': 'Connectez-vous à un fournisseur de modèles pour générer une revue.',
'walkthrough.blocked.noProviderLogin.title': 'Ce fournisseur nest pas connecté',
'walkthrough.blocked.noProviderLogin.description': '{model} nécessite une connexion à son fournisseur. Connectez-vous, ou choisissez un modèle dun 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 dun fournisseur que vous utilisez déjà.',
'walkthrough.blocked.emptyDiff.title': 'Rien à examiner', 'walkthrough.blocked.emptyDiff.title': 'Rien à examiner',
'walkthrough.blocked.emptyDiff.description': 'Il ny a encore aucune modification dans cette portée.', 'walkthrough.blocked.emptyDiff.description': 'Il ny a encore aucune modification dans cette portée.',
'walkthrough.blocked.contextTooSmall.title': 'Ce diff est trop volumineux pour le modèle actuel', 'walkthrough.blocked.contextTooSmall.title': 'Ce diff est trop volumineux pour le modèle actuel',
+3
View File
@@ -1144,6 +1144,9 @@ export const dict: Record<I18nKey, string> = {
'walkthrough.importance.context': '補足', 'walkthrough.importance.context': '補足',
'walkthrough.blocked.noModel.title': '利用できるスモールモデルがありません', 'walkthrough.blocked.noModel.title': '利用できるスモールモデルがありません',
'walkthrough.blocked.noModel.description': 'レビューを生成するにはモデルプロバイダーにサインインしてください。', 'walkthrough.blocked.noModel.description': 'レビューを生成するにはモデルプロバイダーにサインインしてください。',
'walkthrough.blocked.noProviderLogin.title': 'このプロバイダーにはサインインしていません',
'walkthrough.blocked.noProviderLogin.description': '{model} にはプロバイダーへのログインが必要です。サインインするか、すでに使っているプロバイダーのモデルを選んでください。',
'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': '選択したモデルにはプロバイダーへのログインが必要です。サインインするか、すでに使っているプロバイダーのモデルを選んでください。',
'walkthrough.blocked.emptyDiff.title': 'レビュー対象がありません', 'walkthrough.blocked.emptyDiff.title': 'レビュー対象がありません',
'walkthrough.blocked.emptyDiff.description': 'この範囲にはまだ変更がありません。', 'walkthrough.blocked.emptyDiff.description': 'この範囲にはまだ変更がありません。',
'walkthrough.blocked.contextTooSmall.title': 'この差分は現在のモデルには大きすぎます', 'walkthrough.blocked.contextTooSmall.title': 'この差分は現在のモデルには大きすぎます',
+3
View File
@@ -1148,6 +1148,9 @@ export const dict: Record<I18nKey, string> = {
'walkthrough.importance.context': '참고', 'walkthrough.importance.context': '참고',
'walkthrough.blocked.noModel.title': '사용할 수 있는 스몰 모델이 없습니다', 'walkthrough.blocked.noModel.title': '사용할 수 있는 스몰 모델이 없습니다',
'walkthrough.blocked.noModel.description': '리뷰를 생성하려면 모델 제공자에 로그인하세요.', 'walkthrough.blocked.noModel.description': '리뷰를 생성하려면 모델 제공자에 로그인하세요.',
'walkthrough.blocked.noProviderLogin.title': '이 제공자에 로그인되어 있지 않습니다',
'walkthrough.blocked.noProviderLogin.description': '{model}을(를) 쓰려면 해당 제공자에 로그인해야 합니다. 로그인하거나, 이미 사용 중인 제공자의 모델을 선택하세요.',
'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': '선택한 모델을 쓰려면 해당 제공자에 로그인해야 합니다. 로그인하거나, 이미 사용 중인 제공자의 모델을 선택하세요.',
'walkthrough.blocked.emptyDiff.title': '리뷰할 내용이 없습니다', 'walkthrough.blocked.emptyDiff.title': '리뷰할 내용이 없습니다',
'walkthrough.blocked.emptyDiff.description': '이 범위에는 아직 변경 사항이 없습니다.', 'walkthrough.blocked.emptyDiff.description': '이 범위에는 아직 변경 사항이 없습니다.',
'walkthrough.blocked.contextTooSmall.title': '이 diff는 현재 모델에 너무 큽니다', 'walkthrough.blocked.contextTooSmall.title': '이 diff는 현재 모델에 너무 큽니다',
+3
View File
@@ -1460,6 +1460,9 @@ export const dict: Record<I18nKey, string> = {
'walkthrough.importance.context': 'Kontekst', 'walkthrough.importance.context': 'Kontekst',
'walkthrough.blocked.noModel.title': 'Brak dostępnego małego modelu', '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.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.title': 'Nie ma czego przeglądać',
'walkthrough.blocked.emptyDiff.description': 'W tym zakresie nie ma jeszcze zmian.', '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', 'walkthrough.blocked.contextTooSmall.title': 'Te różnice są za duże dla bieżącego modelu',
@@ -1148,6 +1148,9 @@ export const dict: Record<I18nKey, string> = {
"walkthrough.importance.context": "Contexto", "walkthrough.importance.context": "Contexto",
"walkthrough.blocked.noModel.title": "Nenhum modelo pequeno disponível", "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.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.title": "Nada para revisar",
"walkthrough.blocked.emptyDiff.description": "Ainda não há mudanças neste escopo.", "walkthrough.blocked.emptyDiff.description": "Ainda não há mudanças neste escopo.",
"walkthrough.blocked.contextTooSmall.title": "Este diff é grande demais para o modelo atual", "walkthrough.blocked.contextTooSmall.title": "Este diff é grande demais para o modelo atual",
+3
View File
@@ -1148,6 +1148,9 @@ export const dict: Record<I18nKey, string> = {
"walkthrough.importance.context": "Контекст", "walkthrough.importance.context": "Контекст",
"walkthrough.blocked.noModel.title": "Немає доступної small model", "walkthrough.blocked.noModel.title": "Немає доступної small model",
"walkthrough.blocked.noModel.description": "Увійдіть до провайдера моделей, щоб створити розбір.", "walkthrough.blocked.noModel.description": "Увійдіть до провайдера моделей, щоб створити розбір.",
"walkthrough.blocked.noProviderLogin.title": "У цей провайдер не ввійшли",
"walkthrough.blocked.noProviderLogin.description": "{model} потребує входу в його провайдер. Увійдіть або виберіть модель у провайдера, яким ви вже користуєтесь.",
"walkthrough.blocked.noProviderLogin.descriptionUnknownModel": "Вибрана модель потребує входу в її провайдер. Увійдіть або виберіть модель у провайдера, яким ви вже користуєтесь.",
"walkthrough.blocked.emptyDiff.title": "Немає що оглядати", "walkthrough.blocked.emptyDiff.title": "Немає що оглядати",
"walkthrough.blocked.emptyDiff.description": "У цій області поки що немає змін.", "walkthrough.blocked.emptyDiff.description": "У цій області поки що немає змін.",
"walkthrough.blocked.contextTooSmall.title": "Цей diff завеликий для поточної моделі", "walkthrough.blocked.contextTooSmall.title": "Цей diff завеликий для поточної моделі",
@@ -1148,6 +1148,9 @@ export const dict: Record<I18nKey, string> = {
'walkthrough.importance.context': '背景', 'walkthrough.importance.context': '背景',
'walkthrough.blocked.noModel.title': '没有可用的小模型', 'walkthrough.blocked.noModel.title': '没有可用的小模型',
'walkthrough.blocked.noModel.description': '请登录模型提供方后再生成评审。', 'walkthrough.blocked.noModel.description': '请登录模型提供方后再生成评审。',
'walkthrough.blocked.noProviderLogin.title': '尚未登录此提供方',
'walkthrough.blocked.noProviderLogin.description': '{model} 需要登录其提供方。请先登录,或改选你已在使用的提供方中的模型。',
'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': '所选模型需要登录其提供方。请先登录,或改选你已在使用的提供方中的模型。',
'walkthrough.blocked.emptyDiff.title': '没有可评审的内容', 'walkthrough.blocked.emptyDiff.title': '没有可评审的内容',
'walkthrough.blocked.emptyDiff.description': '该范围内暂无改动。', 'walkthrough.blocked.emptyDiff.description': '该范围内暂无改动。',
'walkthrough.blocked.contextTooSmall.title': '当前模型无法容纳这份差异', 'walkthrough.blocked.contextTooSmall.title': '当前模型无法容纳这份差异',
@@ -1160,6 +1160,9 @@ export const dict: Record<I18nKey, string> = {
'walkthrough.importance.context': '背景', 'walkthrough.importance.context': '背景',
'walkthrough.blocked.noModel.title': '沒有可用的小模型', 'walkthrough.blocked.noModel.title': '沒有可用的小模型',
'walkthrough.blocked.noModel.description': '請先登入模型供應商再產生審閱。', 'walkthrough.blocked.noModel.description': '請先登入模型供應商再產生審閱。',
'walkthrough.blocked.noProviderLogin.title': '尚未登入此供應商',
'walkthrough.blocked.noProviderLogin.description': '{model} 需要登入其供應商。請先登入,或改選你已在使用的供應商中的模型。',
'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': '所選模型需要登入其供應商。請先登入,或改選你已在使用的供應商中的模型。',
'walkthrough.blocked.emptyDiff.title': '沒有可審閱的內容', 'walkthrough.blocked.emptyDiff.title': '沒有可審閱的內容',
'walkthrough.blocked.emptyDiff.description': '此範圍目前沒有變更。', 'walkthrough.blocked.emptyDiff.description': '此範圍目前沒有變更。',
'walkthrough.blocked.contextTooSmall.title': '目前模型無法容納這份差異', 'walkthrough.blocked.contextTooSmall.title': '目前模型無法容納這份差異',
+1
View File
@@ -91,6 +91,7 @@ export type WalkthroughStage = 'collecting' | 'asking' | 'retrying' | 'assemblin
export type WalkthroughBlockedReason = export type WalkthroughBlockedReason =
| 'no-model' | 'no-model'
| 'no-provider-login'
| 'empty-diff' | 'empty-diff'
| 'only-generated' | 'only-generated'
| 'context-too-small' | 'context-too-small'
@@ -69,10 +69,17 @@ other runtime API.
- `timeoutMs` overrides the 60s default per call; `signal` lets a caller abort - `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. a request that is no longer wanted. Both apply to every wire format.
- `describeSmallModel()` additionally reports `inputCharBudget`, - `describeSmallModel()` additionally reports `inputCharBudget`,
`contextTokens`, `contextKnown`, and `structuredOutput`. The last is `contextTokens`, `contextKnown`, `structuredOutput`, and `hasLogin`. The last
tri-state: `true`/`false` from the catalog, `null` when the catalog omits the is whether the resolved provider has a usable credential (`auth.json` or
field — which it does for roughly half of all models, aggregators and proxies config `provider.<id>.options.apiKey`) — settings/config overrides can name a
especially. Callers must treat `null` as "try it", not "unsupported". 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 - `call.js` — wire formats and per-provider auth, replicating OpenCode's
plugin auth loaders: plugin auth loaders:
- **GitHub Copilot**: fetches the requested model's authenticated `/models` - **GitHub Copilot**: fetches the requested model's authenticated `/models`
+19 -3
View File
@@ -566,15 +566,31 @@ const readProviderConfig = (workingDirectory, providerID) => {
// Dispatch // Dispatch
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/**
* Same credential resolution the request path uses: config
* `provider.<id>.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 }) { 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 tokens = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : DEFAULT_MAX_OUTPUT_TOKENS;
const providerConfig = readProviderConfig(workingDirectory, providerID); const providerConfig = readProviderConfig(workingDirectory, providerID);
// Match OpenCode's resolveSDK precedence: // Match OpenCode's resolveSDK precedence:
// config provider.<id>.options.apiKey (providerConfig.auth) wins; the // config provider.<id>.options.apiKey wins; the auth.json entry is only a fallback.
// auth.json entry is only a fallback.
const entry = providerConfig?.auth || getAuthEntryForProvider(auth, providerID); const entry = providerConfig?.auth || getAuthEntryForProvider(auth, providerID);
if (!entry) { 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') { if (providerID === 'github-copilot') {
@@ -171,14 +171,21 @@ describe('callSmallModel — custom provider config', () => {
provider: { custom: { options: { baseURL: 'https://proxy.example.test/v1' } } }, provider: { custom: { options: { baseURL: 'https://proxy.example.test/v1' } } },
}); });
await expect(callSmallModel({ const error = await callSmallModel({
auth: {}, auth: {},
catalog: {}, catalog: {},
workingDirectory: '/proj', workingDirectory: '/proj',
providerID: 'custom', providerID: 'custom',
modelID: 'gpt-4o-mini', modelID: 'gpt-4o-mini',
prompt: 'hi', 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. // The credential gate fires before any network call.
expect(fetchMock).not.toHaveBeenCalled(); expect(fetchMock).not.toHaveBeenCalled();
+10 -1
View File
@@ -5,7 +5,7 @@ import { readAuthFile } from '../opencode/auth.js';
import { readConfigLayers } from '../opencode/shared.js'; import { readConfigLayers } from '../opencode/shared.js';
import { getModelCatalog } from './catalog.js'; import { getModelCatalog } from './catalog.js';
import { resolveSmallModel, parseModelRef, isUsableAuthEntry, getAuthEntryForProvider } from './resolve.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( const OPENCHAMBER_SETTINGS_FILE = path.join(
process.env.OPENCHAMBER_DATA_DIR process.env.OPENCHAMBER_DATA_DIR
@@ -252,8 +252,17 @@ export async function describeSmallModel({ directory, preferredProviderID, prefe
outputReserveTokens: reserveTokens, 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 { return {
...resolved, ...resolved,
hasLogin,
inputCharBudget: maxChars, inputCharBudget: maxChars,
contextTokens, contextTokens,
contextKnown, contextKnown,
@@ -18,7 +18,13 @@ vi.mock('./catalog.js', () => ({
getModelCatalog: vi.fn(), getModelCatalog: vi.fn(),
getCatalogProvider: 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 { generateSmallModelText, describeSmallModel } = await import('./index.js');
const { readAuthFile } = await import('../opencode/auth.js'); const { readAuthFile } = await import('../opencode/auth.js');
@@ -126,6 +132,19 @@ describe('describeSmallModel — capability reporting', () => {
contextTokens: 8_000, contextTokens: 8_000,
contextKnown: true, contextKnown: true,
structuredOutput: 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,
}); });
}); });
@@ -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 blocked walkthrough writes this setting too, so recovering from a refusal never
silently changes the model behind commit messages. 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.<id>.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 ## Output language
A walkthrough its reader cannot read is worth nothing, so the prose language is A walkthrough its reader cannot read is worth nothing, so the prose language is
@@ -333,6 +333,12 @@ function computeReadiness({ model, digest, files, fileCount, hunkCount, generate
return { ready: false, reason, model, generatedFileCount }; 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 // 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 // part of the prompt, so a readiness answer computed without it would be
// measuring a request nobody is going to send. // measuring a request nobody is going to send.
@@ -392,6 +398,13 @@ async function runGeneration({ directory, source, repoRoot, key, force, explicit
if (!model) { if (!model) {
throw fail('No model is available — sign in to a provider first', 404, { code: 'no-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); const { digest, files, idByAlias, fileCount, hunkCount, generatedFileCount } = await loadCurrentDiff(directory, source, deps);
setStage(repoRoot, key, 'asking'); setStage(repoRoot, key, 'asking');
@@ -494,6 +507,9 @@ async function runGeneration({ directory, source, repoRoot, key, force, explicit
if (error?.code === 'output-exhausted') { if (error?.code === 'output-exhausted') {
return fail(error.message, 409, { code: 'output-exhausted', model }); 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; return null;
}; };
@@ -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));
}
});
});