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:
co-authored by
Serhii Dziupin
parent
f47110c66f
commit
abb396e080
@@ -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`
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user