165 lines
6.5 KiB
JavaScript
165 lines
6.5 KiB
JavaScript
// Provider state that exists only inside the running OpenCode process.
|
|
//
|
|
// A plugin registers its provider from the `config` hook and supplies the
|
|
// credential from its `auth` loader, both at startup. Neither ends up in
|
|
// `opencode.json` or `auth.json`, so a server that only reads files sees
|
|
// nothing — which is why plugin-backed models used to fail here with
|
|
// "has no known API base URL" while working fine in chat (#2666).
|
|
//
|
|
// `GET /provider` is where that state becomes visible. It reports, per
|
|
// provider, the resolved `options.baseURL` and `options.apiKey`, and per model
|
|
// the wire adapter (`api.npm`) and endpoint (`api.url`). The provider-level
|
|
// endpoint remains only as a fallback when the selected model has no endpoint.
|
|
//
|
|
// What it does NOT report is `options.fetch`. OpenCode strips functions from
|
|
// the response, and a plugin is free to put its whole protocol in there:
|
|
// rewriting the path, signing the request, translating the payload. Such a
|
|
// provider advertises a perfectly ordinary base URL that answers nothing we
|
|
// know how to ask, and no field distinguishes the two.
|
|
//
|
|
// Asking the endpoint (`GET /models`) looked like the way to tell them apart,
|
|
// and it does answer correctly for that case — but measured against the 166
|
|
// providers in the models.dev catalog it also denies six that work fine and
|
|
// simply have no `/models` route. A provider that vanishes from the picker
|
|
// explains nothing; one that fails on use says why. So this module reports
|
|
// what it knows and leaves the verdict to the call itself.
|
|
|
|
const SNAPSHOT_TTL_MS = 30_000;
|
|
const SNAPSHOT_TIMEOUT_MS = 5_000;
|
|
|
|
// opencode zen hands out this sentinel instead of a key when the user has no
|
|
// zen login, and trims its catalog to the free models. Those run on OpenCode's
|
|
// own subsidised infrastructure and are meant to be reached through OpenCode,
|
|
// not by us. Treating the sentinel as a credential would do exactly that, so
|
|
// it is never accepted as one.
|
|
export const ZEN_ANONYMOUS_API_KEY = 'public';
|
|
|
|
let connection = null;
|
|
let snapshot = null;
|
|
let snapshotAt = 0;
|
|
let inflight = null;
|
|
|
|
/**
|
|
* Wires this module to the running OpenCode instance. Called once at server
|
|
* startup; pass `null` to detach. Until it is wired every lookup answers
|
|
* "nothing known", which leaves the file-based resolution unchanged.
|
|
*/
|
|
export function configureOpenCodeRuntimeProviders(next) {
|
|
connection = next ?? null;
|
|
resetOpenCodeRuntimeProviders();
|
|
}
|
|
|
|
/**
|
|
* Drops every cached answer. OpenCode restarts reload plugins, which can
|
|
* change ports, keys and the provider list itself.
|
|
*/
|
|
export function resetOpenCodeRuntimeProviders() {
|
|
snapshot = null;
|
|
snapshotAt = 0;
|
|
inflight = null;
|
|
}
|
|
|
|
/**
|
|
* The boundary. Everything the `/provider` payload claims is checked here, so
|
|
* the rest of this module and its callers work with settled values:
|
|
* a credential we may use, provider and model endpoints, and whether the
|
|
* provider is the anonymous zen case.
|
|
*
|
|
* The credential deliberately prefers `options.apiKey` over the `key` field:
|
|
* for a plugin provider the former is what its auth loader produced and what
|
|
* OpenCode itself sends, while `key` only carries env/auth.json values this
|
|
* server can already read from disk.
|
|
*/
|
|
function parseProviderListing(payload) {
|
|
const providers = new Map();
|
|
const connected = new Set();
|
|
if (!payload || typeof payload !== 'object') return { providers, connected };
|
|
|
|
const text = (value) => (typeof value === 'string' && value.trim() ? value.trim() : null);
|
|
const record = (value) => (value && typeof value === 'object' ? value : {});
|
|
const endpoint = (value) => text(value)?.replace(/\/+$/, '') ?? null;
|
|
|
|
for (const raw of Array.isArray(payload.all) ? payload.all : []) {
|
|
const id = text(record(raw).id);
|
|
if (!id) continue;
|
|
const options = record(record(raw).options);
|
|
const models = new Map();
|
|
for (const [modelID, rawModel] of Object.entries(record(record(raw).models))) {
|
|
const model = record(rawModel);
|
|
const api = record(model.api);
|
|
const modelURL = endpoint(api.url);
|
|
const modelNpm = text(api.npm);
|
|
models.set(modelID, { api: { url: modelURL, npm: modelNpm } });
|
|
}
|
|
const firstModel = models.values().next().value;
|
|
const declaredKey = text(options.apiKey);
|
|
providers.set(id, {
|
|
id,
|
|
source: text(record(raw).source),
|
|
apiKey: declaredKey === ZEN_ANONYMOUS_API_KEY ? null : (declaredKey ?? text(record(raw).key)),
|
|
baseURL: endpoint(options.baseURL) ?? firstModel?.api?.url ?? null,
|
|
models,
|
|
// True only for the zen-without-login case: a provider that is present
|
|
// and usable through OpenCode, but that we must not call ourselves.
|
|
anonymousZen: declaredKey === ZEN_ANONYMOUS_API_KEY,
|
|
});
|
|
}
|
|
|
|
// Providers OpenCode considers usable right now. A provider can be present
|
|
// in `all` (it is in the catalog) without any credential behind it.
|
|
for (const raw of Array.isArray(payload.connected) ? payload.connected : []) {
|
|
const id = text(raw);
|
|
if (id) connected.add(id);
|
|
}
|
|
|
|
return { providers, connected };
|
|
}
|
|
|
|
const fetchSnapshot = async () => {
|
|
const response = await fetch(connection.buildOpenCodeUrl('/provider', ''), {
|
|
headers: { Accept: 'application/json', ...connection.getOpenCodeAuthHeaders() },
|
|
signal: AbortSignal.timeout(SNAPSHOT_TIMEOUT_MS),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`OpenCode provider listing failed with ${response.status}`);
|
|
}
|
|
return parseProviderListing(await response.json());
|
|
};
|
|
|
|
/**
|
|
* The current runtime provider snapshot, or `null` when OpenCode cannot be
|
|
* reached.
|
|
*
|
|
* `null` means "unknown", never "no providers": callers must fall back to
|
|
* their file-based resolution rather than treat an unreachable OpenCode as an
|
|
* empty provider list.
|
|
*/
|
|
export async function getRuntimeProviderSnapshot() {
|
|
if (!connection) return null;
|
|
if (snapshot && Date.now() - snapshotAt < SNAPSHOT_TTL_MS) return snapshot;
|
|
if (!inflight) {
|
|
inflight = fetchSnapshot().finally(() => {
|
|
inflight = null;
|
|
});
|
|
}
|
|
try {
|
|
snapshot = await inflight;
|
|
snapshotAt = Date.now();
|
|
return snapshot;
|
|
} catch {
|
|
// Keep serving the previous snapshot when there is one: a momentarily
|
|
// unreachable OpenCode should not retract providers that were resolving a
|
|
// second ago.
|
|
return snapshot;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Runtime credential and endpoint for one provider, or `null` when OpenCode
|
|
* knows nothing about it.
|
|
*/
|
|
export async function getRuntimeProvider(providerID) {
|
|
const current = await getRuntimeProviderSnapshot();
|
|
return current?.providers.get(providerID) ?? null;
|
|
}
|