Files
openchamber/packages/web/server/lib/small-model/runtime-providers.js
T

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;
}