feat(small-model): resolve plugin-registered providers from the running OpenCode
Plugin providers are registered from a plugin's `config` hook and credentialed from its `auth` loader, both inside the running OpenCode process. Nothing about them reaches `opencode.json` or `auth.json`, so resolution that only reads files could not see them: selecting such a model failed with "has no known API base URL" while the same model worked in chat (#2666). `GET /provider` is where that state is visible. A new `runtime-providers` module keeps one cached snapshot of it and reports, per provider, the credential and endpoint OpenCode itself resolved. Credential resolution becomes config -> runtime -> auth.json, and endpoint resolution config -> openai default -> runtime -> models.dev catalog. Providers with a dedicated wire format (Copilot, ChatGPT-plan OpenAI, Anthropic, Google) are excluded from the runtime credential: for them OpenCode reports an OAuth access token that their real transport does not accept. opencode zen is excluded when the user has no zen login. OpenCode then reports the sentinel `apiKey: "public"` and trims its catalog to free models that run on its own infrastructure; the sentinel is never read as a credential. Claude Code stays refused for background actions even when a plugin publishes an OpenAI-compatible endpoint for it, because that endpoint is a facade over the Claude Agent SDK and spawns the CLI per request. No capability probe. Asking `GET /models` does identify a plugin whose protocol lives in its own `fetch`, but measured across the 166 providers with an `api` URL in the models.dev catalog it also denies six that work and simply have no `/models` route. A provider that vanishes from the picker explains nothing, while one that fails on use says why, so availability stops at credential and endpoint. The same list drives the Small Model and Changes Walkthrough pickers. Validated against a real OpenCode with four plugin providers loaded: offered providers went from 3 to 7, zen and Claude Code stayed out, and a generation through a plugin-backed model that previously failed now returns.
This commit is contained in:
@@ -5,7 +5,16 @@ 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, resolveProviderLogin } from './call.js';
|
||||
import { DEDICATED_WIRE_FORMAT_PROVIDERS, callSmallModel, resolveProviderLogin } from './call.js';
|
||||
import { getRuntimeProviderSnapshot } from './runtime-providers.js';
|
||||
|
||||
// Never a small model, whatever the transport looks like. A plugin can publish
|
||||
// an OpenAI-compatible endpoint for Claude Code, but it is a façade over the
|
||||
// Claude Agent SDK, which spawns the Claude Code CLI per request and spends
|
||||
// the user's Claude subscription rate limit. Paying that for a session title
|
||||
// or a summary is the wrong trade, so the refusal is unconditional rather than
|
||||
// conditional on an endpoint existing.
|
||||
const CLAUDE_CODE_PROVIDER = 'claude-code';
|
||||
|
||||
const OPENCHAMBER_SETTINGS_FILE = path.join(
|
||||
process.env.OPENCHAMBER_DATA_DIR
|
||||
@@ -120,7 +129,7 @@ export async function generateSmallModelText({ prompt, system, maxOutputTokens,
|
||||
);
|
||||
}
|
||||
|
||||
if (resolved.providerID === 'claude-code') {
|
||||
if (resolved.providerID === CLAUDE_CODE_PROVIDER) {
|
||||
throw Object.assign(
|
||||
new Error('Claude Code cannot be used for background small-model actions. Choose another Small Model in Settings → Sessions.'),
|
||||
{ statusCode: 422, code: 'small-model-provider-unsupported' },
|
||||
@@ -180,28 +189,62 @@ export async function generateSmallModelText({ prompt, system, maxOutputTokens,
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider ids with a usable OpenCode login — the set the small model can
|
||||
* actually call. Used by the settings override picker to hide providers that
|
||||
* would only ever fail (e.g. opencode free models without a token).
|
||||
* Provider ids the small model can actually call — an auth.json login, or a
|
||||
* credential and endpoint the running OpenCode resolved for a plugin. Used by
|
||||
* the Small Model and Changes Walkthrough pickers to hide providers that would
|
||||
* only ever fail (e.g. opencode free models without a token).
|
||||
*/
|
||||
export function listAuthenticatedProviders() {
|
||||
export async function listAuthenticatedProviders() {
|
||||
try {
|
||||
const auth = readAuthFile();
|
||||
const ids = new Set(
|
||||
Object.keys(auth || {}).filter((providerID) => isUsableAuthEntry(auth[providerID])),
|
||||
);
|
||||
ids.delete('claude-code');
|
||||
// The catalog id is github-copilot while legacy auth entries may sit
|
||||
// under the copilot alias.
|
||||
if (isUsableAuthEntry(getAuthEntryForProvider(auth, 'github-copilot'))) {
|
||||
ids.add('github-copilot');
|
||||
}
|
||||
// Kept separate so a runtime lookup that goes wrong costs the providers it
|
||||
// would have added, never the logins already established from disk.
|
||||
try {
|
||||
for (const providerID of await listRuntimeCallableProviders()) ids.add(providerID);
|
||||
} catch {
|
||||
// The auth.json set below stands on its own.
|
||||
}
|
||||
ids.delete(CLAUDE_CODE_PROVIDER);
|
||||
return Array.from(ids);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Providers that only the running OpenCode knows about — plugin-registered
|
||||
* ones, and any whose endpoint is resolved at startup.
|
||||
*
|
||||
* The test is the same one applied to an auth.json login: a credential we may
|
||||
* use and somewhere to send it. Whether the endpoint answers the protocol we
|
||||
* speak is not knowable from any field OpenCode reports, and guessing it wrong
|
||||
* removes a working model from the picker with nothing to explain it.
|
||||
*/
|
||||
async function listRuntimeCallableProviders() {
|
||||
const snapshot = await getRuntimeProviderSnapshot();
|
||||
if (!snapshot) return [];
|
||||
const ids = [];
|
||||
for (const id of snapshot.connected) {
|
||||
const provider = snapshot.providers.get(id);
|
||||
// No credential we may use — including the zen sentinel, whose free models
|
||||
// belong to OpenCode's own server.
|
||||
if (!provider?.apiKey || !provider.baseURL) continue;
|
||||
// Reached through a dedicated wire format and already covered by the
|
||||
// auth.json scan above.
|
||||
if (DEDICATED_WIRE_FORMAT_PROVIDERS.has(id)) continue;
|
||||
ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports which model would be used, without calling it.
|
||||
*
|
||||
@@ -262,7 +305,7 @@ export async function describeSmallModel({ directory, preferredProviderID, prefe
|
||||
|
||||
// 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({
|
||||
const hasLogin = Boolean(await resolveProviderLogin({
|
||||
auth,
|
||||
workingDirectory: directory,
|
||||
providerID: resolved.providerID,
|
||||
|
||||
Reference in New Issue
Block a user