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:
Bohdan Triapitsyn
2026-08-20 00:47:34 +03:00
parent 52ebe51122
commit 6a09c63392
10 changed files with 606 additions and 32 deletions
+46 -12
View File
@@ -5,6 +5,7 @@ import { readAuthFile, writeAuthFile } from '../opencode/auth.js';
import { readConfig, readConfigLayers } from '../opencode/shared.js';
import { getCatalogProvider } from './catalog.js';
import { getAuthEntryForProvider } from './resolve.js';
import { getRuntimeProvider } from './runtime-providers.js';
// Direct, non-streaming text generation against the provider APIs, replicating
// how OpenCode authenticates each of them (see the plugin auth loaders in the
@@ -566,23 +567,52 @@ const readProviderConfig = (workingDirectory, providerID) => {
// Dispatch
// ---------------------------------------------------------------------------
/**
* Providers reached through a dedicated wire format below: a token exchange,
* an OAuth refresh, or a non-bearer header. OpenCode's runtime
* `options.apiKey` is not the value those branches need — the ChatGPT-plan
* `openai` login is the clearest case, where the runtime key is an OAuth
* access token that api.openai.com answers with 401 — so the runtime
* credential never stands in for them, and the runtime listing skips them
* because the auth.json scan already covers them.
*/
export const DEDICATED_WIRE_FORMAT_PROVIDERS = new Set(['github-copilot', 'copilot', 'openai', 'anthropic', 'google']);
/**
* The runtime credential shaped as an auth entry, or `null` when the provider
* owns its credential handling or OpenCode reports nothing usable.
*/
const runtimeCredential = (providerID, runtime) => (
!DEDICATED_WIRE_FORMAT_PROVIDERS.has(providerID) && runtime?.apiKey
? { type: 'api', key: runtime.apiKey }
: null
);
/**
* Same credential resolution the request path uses: config
* `provider.<id>.options.apiKey` wins, then the auth.json entry.
* `provider.<id>.options.apiKey` wins, then the runtime credential OpenCode
* resolved for a plugin provider, 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 }) {
export async function resolveProviderLogin({ auth, workingDirectory, providerID }) {
const providerConfig = readProviderConfig(workingDirectory, providerID);
return providerConfig?.auth || getAuthEntryForProvider(auth, providerID) || null;
return providerConfig?.auth
|| runtimeCredential(providerID, await getRuntimeProvider(providerID))
|| 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 wins; the auth.json entry is only a fallback.
const entry = providerConfig?.auth || getAuthEntryForProvider(auth, providerID);
const runtimeProvider = await getRuntimeProvider(providerID);
// Match OpenCode's resolveSDK precedence: config `provider.<id>.options`
// wins, then what OpenCode itself resolved at runtime (the only place a
// plugin's credential exists), and the auth.json entry last.
const entry = providerConfig?.auth
|| runtimeCredential(providerID, runtimeProvider)
|| getAuthEntryForProvider(auth, providerID);
if (!entry) {
// Structured so the walkthrough (and any other caller) can show a blocker
// instead of a raw 500 banner with this developer-oriented sentence.
@@ -685,9 +715,12 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider
// Everything else: OpenAI-compatible chat completions against the catalog's
// base URL for that provider (openai itself included). When a custom provider
// is not in the catalog (e.g. a user-configured OpenAI-compatible proxy),
// fall back to its baseURL from the OpenCode provider config. The openai
// provider also respects provider.openai.options.baseURL — OpenCode itself
// uses the same config for all providers including openai.
// fall back to its baseURL from the OpenCode provider config, then to the
// endpoint OpenCode resolved at runtime — which for a plugin provider is the
// only place it exists, and for several of them is a local proxy the plugin
// itself runs. The openai provider also respects
// provider.openai.options.baseURL — OpenCode itself uses the same config for
// all providers including openai.
const provider = getCatalogProvider(catalog, providerID);
const providerConfigUrl = providerConfig?.baseURL;
const defaultOpenaiUrl = 'https://api.openai.com/v1';
@@ -695,9 +728,10 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider
? providerConfigUrl
: providerID === 'openai'
? defaultOpenaiUrl
: typeof provider?.api === 'string' && provider.api
? provider.api
: null;
: runtimeProvider?.baseURL
?? (typeof provider?.api === 'string' && provider.api
? provider.api
: null);
if (!baseURL) {
throw new Error(`Provider "${providerID}" has no known API base URL`);
}