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
@@ -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.<id>.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`
+19 -3
View File
@@ -566,15 +566,31 @@ const readProviderConfig = (workingDirectory, providerID) => {
// 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 }) {
const tokens = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : DEFAULT_MAX_OUTPUT_TOKENS;
const providerConfig = readProviderConfig(workingDirectory, providerID);
// Match OpenCode's resolveSDK precedence:
// config provider.<id>.options.apiKey (providerConfig.auth) wins; the
// auth.json entry is only a fallback.
// config provider.<id>.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') {
@@ -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();
+10 -1
View File
@@ -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,
@@ -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,
});
});