diff --git a/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts b/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts index 9da08a9c5..c18a5808e 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts @@ -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); +}); diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 63d29e370..7d924d4fb 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -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 }; diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index c1d3ae502..937e70f72 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -568,6 +568,7 @@ function buildOpenCodeModelContextWindowLookup( connected?: string[]; all?: Array<{ id: string; + source?: string; models?: Record; }>; } @@ -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; }