fix(server): include OpenCode console subscription providers in model list (#917)

* fix(server): include OpenCode console subscription providers in model list

Providers with source "api" (e.g. Pi coding agent via OpenCode Go
subscription) appear in providers.all but not in providers.connected,
which only lists env/config providers. Paseo was filtering exclusively
by connected, so subscription-managed providers were silently dropped
and users saw an empty model list or a misleading "set env vars" error.

Fix: treat source === "api" as accessible in both listModels() and the
context-window lookup helper. Also update the error message to mention
the console login option.

chore: add portless as a devDependency so npm run dev works without
a global install.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: remove portless dependency

* test(server): add unit tests for OpenCode api-source provider inclusion

Covers the source="api" logic added in 08e3c6bf: providers managed by the
OpenCode console/subscription must appear in listModels and the context window
lookup even when absent from the connected list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: drop unrelated package-lock.json churn

The lockfile changes were peer metadata additions from a newer npm
version, not actual dependency changes. Restore to match main.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
Chuan
2026-05-13 18:48:00 +08:00
committed by GitHub
co-authored by Claude Sonnet 4.6 Mohamed Boudra
parent 77dc80b86f
commit 6ed7dfc42a
3 changed files with 151 additions and 7 deletions
@@ -72,3 +72,94 @@ test("passes explicit refresh force through server acquisition", async () => {
expect(runtime.acquisitions).toEqual([{ force: true, releaseCount: 1 }]);
});
test("includes models from api-source providers not in connected", async () => {
// Providers with source "api" are managed by the OpenCode console/subscription.
// They don't appear in `connected` but are fully usable.
const runtime = new TestOpenCodeRuntime();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.providerListResponse = {
data: {
connected: [],
all: [
{
id: "pi",
name: "Pi",
source: "api",
models: {
"pi-model-1": {
name: "Pi Model 1",
limit: { context: 200_000 },
},
},
},
],
},
};
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
const models = await client.listModels({ cwd: "/tmp/opencode-models", force: false });
expect(models).toMatchObject([
{
provider: "opencode",
id: "pi/pi-model-1",
label: "Pi Model 1",
},
]);
});
test("throws when no providers are accessible (neither connected nor api-source)", async () => {
const runtime = new TestOpenCodeRuntime();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.providerListResponse = {
data: {
connected: [],
all: [
{
id: "anthropic",
name: "Anthropic",
source: "env",
models: {
"claude-opus": { name: "Claude Opus", limit: { context: 1_000_000 } },
},
},
],
},
};
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
await expect(client.listModels({ cwd: "/tmp/opencode-models", force: false })).rejects.toThrow(
"OpenCode has no connected providers",
);
});
test("does not throw when only api-source providers are present with no connected providers", async () => {
const runtime = new TestOpenCodeRuntime();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.providerListResponse = {
data: {
connected: [],
all: [
{
id: "pi",
name: "Pi",
source: "api",
models: {
"pi-model-1": { name: "Pi Model 1", limit: { context: 200_000 } },
},
},
],
},
};
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
await expect(
client.listModels({ cwd: "/tmp/opencode-models", force: false }),
).resolves.toHaveLength(1);
});
@@ -483,6 +483,50 @@ describe("OpenCode adapter context-window normalization", () => {
).toBeUndefined();
});
test("includes api-source providers in context window lookup even when absent from connected", () => {
// Providers with source "api" are managed by the OpenCode console/subscription and are
// usable even when they don't appear in `connected`.
const lookup = __openCodeInternals.buildOpenCodeModelContextWindowLookup({
connected: [],
all: [
{
id: "pi",
source: "api",
models: {
"pi-model-1": { limit: { context: 200_000 } },
},
},
],
});
expect(lookup.get("pi/pi-model-1")).toBe(200_000);
});
test("excludes non-api-source providers absent from connected in context window lookup", () => {
const lookup = __openCodeInternals.buildOpenCodeModelContextWindowLookup({
connected: ["openai"],
all: [
{
id: "openai",
source: "env",
models: {
"gpt-5": { limit: { context: 400_000 } },
},
},
{
id: "anthropic",
source: "env",
models: {
"claude-opus": { limit: { context: 1_000_000 } },
},
},
],
});
expect(lookup.get("openai/gpt-5")).toBe(400_000);
expect(lookup.get("anthropic/claude-opus")).toBeUndefined();
});
test("normalizes step-finish usage into AgentUsage context window fields", () => {
const usage = { contextWindowMaxTokens: 400_000 };
@@ -568,6 +568,7 @@ function buildOpenCodeModelContextWindowLookup(
connected?: string[];
all?: Array<{
id: string;
source?: string;
models?: Record<string, unknown>;
}>;
}
@@ -581,7 +582,9 @@ function buildOpenCodeModelContextWindowLookup(
const connectedProviderIds = new Set(providers.connected ?? []);
for (const provider of providers.all ?? []) {
if (!connectedProviderIds.has(provider.id)) {
// Providers with source "api" are managed by the OpenCode console/subscription and are
// usable even though they don't appear in `connected` (which only lists env/config providers).
if (!connectedProviderIds.has(provider.id) && provider.source !== "api") {
continue;
}
for (const [modelId, modelDefinition] of Object.entries(provider.models ?? {})) {
@@ -1082,21 +1085,27 @@ export class OpenCodeAgentClient implements AgentClient {
return [];
}
// Only include models from connected providers (ones that are actually available)
const connectedProviderIds = new Set(providers.connected);
// Fail fast if no providers are connected
if (connectedProviderIds.size === 0) {
// Providers with source "api" are managed by the OpenCode console/subscription (e.g. Pi
// coding agent). They do not appear in `connected` (which only lists env/config providers)
// but are fully usable — OpenCode authenticates them internally via the console session.
const isAccessible = (provider: { id: string; source: string }): boolean =>
connectedProviderIds.has(provider.id) || provider.source === "api";
// Fail fast if no providers are accessible at all
if (!providers.all.some(isAccessible)) {
throw new Error(
"OpenCode has no connected providers. Please authenticate with at least one provider (e.g., openai, anthropic) or set appropriate environment variables (e.g., OPENAI_API_KEY).",
"OpenCode has no connected providers. Please authenticate with at least one provider " +
"(e.g., openai, anthropic), set appropriate environment variables (e.g., OPENAI_API_KEY), " +
"or log in to OpenCode Go via the console.",
);
}
const models: AgentModelDefinition[] = [];
this.modelContextWindows.clear();
for (const provider of providers.all) {
// Skip providers that aren't connected/configured
if (!connectedProviderIds.has(provider.id)) {
if (!isAccessible(provider)) {
continue;
}