Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"@effect/platform-node-shared": "catalog:",
"@ff-labs/fff-node": "0.9.4",
"@opencode-ai/sdk": "^1.3.15",
"@opencode/client": "2.0.16",
"diff": "8.0.3",
"effect": "catalog:",
"node-pty": "^1.1.0",
Expand Down
12 changes: 12 additions & 0 deletions apps/server/src/provider/Layers/OpenCodeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
return {
url,
version: "1.15.13",
apiVersion: "v1" as const,
...(serverPassword ? { serverPassword } : {}),
exitCode: Effect.never,
isRunning: Effect.succeed(true),
Expand All @@ -234,6 +235,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
return {
url,
version: "1.15.13",
apiVersion: "v1" as const,
...(serverPassword ? { serverPassword } : {}),
exitCode: null,
external: Boolean(serverUrl),
Expand Down Expand Up @@ -561,6 +563,16 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
cause: null,
}),
),
createOpenCodeV2Client: () =>
({}) as unknown as ReturnType<OpenCodeRuntimeShape["createOpenCodeV2Client"]>,
loadOpenCodeInventoryV2: () =>
Effect.fail(
new OpenCodeRuntimeError({
operation: "loadOpenCodeInventoryV2",
detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventoryV2 not used in this test",
cause: null,
}),
),
loadOpenCodeSkills: () => Effect.succeed([]),
loadInventoryFromCli: () =>
Effect.fail(
Expand Down
62 changes: 61 additions & 1 deletion apps/server/src/provider/Layers/OpenCodeProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ import {
checkOpenCodeProviderStatus,
openCodeCommandsToServerProviderSlashCommands,
} from "./OpenCodeProvider.ts";
import type { OpenCodeInventory } from "../opencodeRuntime.ts";
import type {
OpenCodeApiVersion,
OpenCodeInventory,
OpenCodeV2Inventory,
} from "../opencodeRuntime.ts";
import { readOpenCodeGoUsageLimits } from "./openCodeUsageLimits.ts";
const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings);

Expand Down Expand Up @@ -167,6 +171,9 @@ const runtimeMock = {
directory: string;
serverPassword?: string;
}>,
v2ClientInputs: [] as Array<{ baseUrl: string; serverPassword?: string }>,
apiVersion: "v1" as OpenCodeApiVersion,
inventoryV2: null as OpenCodeV2Inventory | null,
inventory: {
providerList: { connected: [] as string[], all: [] as unknown[], default: {} },
agents: [] as unknown[],
Expand All @@ -182,6 +189,9 @@ const runtimeMock = {
this.state.inventoryCwd = null;
this.state.closeCalls = 0;
this.state.sdkClientInputs.length = 0;
this.state.v2ClientInputs.length = 0;
this.state.apiVersion = "v1";
this.state.inventoryV2 = null;
this.state.inventory = {
providerList: { connected: [], all: [] as unknown[], default: {} },
agents: [] as unknown[],
Expand Down Expand Up @@ -209,6 +219,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
? { serverPassword: effectiveServerPassword }
: {}),
version: "1.14.19",
apiVersion: runtimeMock.state.apiVersion,
isRunning: Effect.succeed(true),
exitCode: Effect.never,
};
Expand All @@ -233,6 +244,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
url: serverUrl ?? "http://127.0.0.1:4301",
...(serverPassword ? { serverPassword } : {}),
version: "1.14.19",
apiVersion: runtimeMock.state.apiVersion,
exitCode: null,
external: Boolean(serverUrl),
};
Expand All @@ -253,6 +265,19 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
runtimeMock.state.sdkClientInputs.push(input);
return {} as unknown as ReturnType<OpenCodeRuntimeShape["createOpenCodeSdkClient"]>;
},
createOpenCodeV2Client: (input) => {
runtimeMock.state.v2ClientInputs.push(input);
return {} as unknown as ReturnType<OpenCodeRuntimeShape["createOpenCodeV2Client"]>;
},
loadOpenCodeInventoryV2: () =>
runtimeMock.state.inventoryV2
? Effect.succeed(runtimeMock.state.inventoryV2)
: Effect.fail(
new OpenCodeRuntimeError({
operation: "loadOpenCodeInventoryV2",
detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventoryV2 not used in this test",
}),
),
loadOpenCodeInventory: () =>
runtimeMock.state.inventoryError
? Effect.fail(
Expand Down Expand Up @@ -433,6 +458,41 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => {
}),
);

it.effect("loads model inventory through the v2 API when the server reports v2", () =>
Effect.gen(function* () {
runtimeMock.state.apiVersion = "v2";
runtimeMock.state.inventoryV2 = {
providers: [{ id: "openai", name: "OpenAI" }],
connectedProviders: ["openai"],
models: [
{ providerID: "openai", modelID: "gpt-5", name: "GPT-5", variantIDs: ["high"] },
{ providerID: "other", modelID: "other-1", name: "Other 1", variantIDs: [] },
],
agents: [{ name: "build", mode: "primary", hidden: false }],
skills: [{ name: "review", description: "Reviews", location: "/skills/review/SKILL.md" }],
commands: [{ name: "init", description: "Init" }],
};

const snapshot = yield* checkProvider(makeOpenCodeSettings());

NodeAssert.equal(snapshot.status, "ready");
NodeAssert.deepEqual(
snapshot.models.map((entry) => entry.slug),
["openai/gpt-5"],
);
NodeAssert.deepEqual(
snapshot.skills.map((skill) => skill.name),
["review"],
);
NodeAssert.deepEqual(
snapshot.slashCommands.map((command) => command.name),
["compact", "init"],
);
NodeAssert.equal(runtimeMock.state.v2ClientInputs.length, 1);
NodeAssert.equal(runtimeMock.state.v2ClientInputs[0]?.baseUrl, "http://127.0.0.1:4301");
}),
);

it.effect("includes OpenCode skills in the provider snapshot", () =>
Effect.gen(function* () {
runtimeMock.state.inventory = {
Expand Down
128 changes: 105 additions & 23 deletions apps/server/src/provider/Layers/OpenCodeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ import {
MINIMUM_OPENCODE_VERSION,
OpenCodeRuntime,
openCodeRuntimeErrorDetail,
type OpenCodeApiVersion,
type OpenCodeInventory,
type OpenCodeV2Inventory,
} from "../opencodeRuntime.ts";
import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2";
import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts";

const OPENCODE_PRESENTATION = {
Expand Down Expand Up @@ -168,7 +169,7 @@ function inferDefaultVariant(
return undefined;
}

function inferDefaultAgent(agents: ReadonlyArray<Agent>): string | undefined {
function inferDefaultAgent(agents: ReadonlyArray<{ readonly name: string }>): string | undefined {
return agents.find((agent) => agent.name === "build")?.name ?? agents[0]?.name ?? undefined;
}

Expand Down Expand Up @@ -201,10 +202,14 @@ const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabi

function openCodeCapabilitiesForModel(input: {
readonly providerID: string;
readonly model: ProviderListResponse["all"][number]["models"][string];
readonly agents: ReadonlyArray<Agent>;
readonly variantIDs: ReadonlyArray<string>;
readonly agents: ReadonlyArray<{
readonly name: string;
readonly mode: string;
readonly hidden?: boolean;
}>;
}): ModelCapabilities {
const rawVariantValues = Object.keys(input.model.variants ?? {});
const rawVariantValues = input.variantIDs;
// When a model advertises no variants, synthesize the standard reasoning
// levels so the composer still offers a Reasoning selector (mirrors the
// Codex/Grok experience where reasoning is always configurable). The set
Expand Down Expand Up @@ -278,7 +283,7 @@ function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray<ServerPr
isCustom: false,
capabilities: openCodeCapabilitiesForModel({
providerID: provider.id,
model,
variantIDs: Object.keys(model.variants ?? {}),
agents: input.agents,
}),
});
Expand All @@ -288,13 +293,50 @@ function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray<ServerPr
return models.toSorted((left, right) => left.name.localeCompare(right.name));
}

function flattenOpenCodeV2Models(input: OpenCodeV2Inventory): ReadonlyArray<ServerProviderModel> {
const connected = new Set(input.connectedProviders);
const displayNames = new Map(input.providers.map((provider) => [provider.id, provider.name]));
const models: Array<ServerProviderModel> = [];

for (const model of input.models) {
if (!connected.has(model.providerID)) {
continue;
}
const name = nonEmptyTrimmed(model.name);
if (!name) {
continue;
}

const subProvider = nonEmptyTrimmed(displayNames.get(model.providerID) ?? "");
models.push({
slug: `${model.providerID}/${model.modelID}`,
name,
...(subProvider ? { subProvider } : {}),
isCustom: false,
capabilities: openCodeCapabilitiesForModel({
providerID: model.providerID,
variantIDs: model.variantIDs,
agents: input.agents,
}),
});
}

return models.toSorted((left, right) => left.name.localeCompare(right.name));
}

function trimOptional(value: string | null | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed && trimmed.length > 0 ? trimmed : undefined;
}

export function openCodeSkillsToServerProviderSkills(
input: OpenCodeInventory["skills"] | undefined,
input:
| ReadonlyArray<{
readonly name?: string | null;
readonly description?: string | null;
readonly location?: string | null;
}>
| undefined,
): ReadonlyArray<ServerProviderSkill> {
const skills: ServerProviderSkill[] = [];
for (const skill of input ?? []) {
Expand All @@ -317,7 +359,14 @@ export function openCodeSkillsToServerProviderSkills(
}

export function openCodeCommandsToServerProviderSlashCommands(
input: OpenCodeInventory["commands"],
input:
| ReadonlyArray<{
readonly name?: string | null;
readonly description?: string | null;
readonly source?: string | null;
readonly hints?: ReadonlyArray<string>;
}>
| undefined,
): ReadonlyArray<ServerProviderSlashCommand> {
const commands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND];
const names = new Set([COMPACT_SLASH_COMMAND.name]);
Expand All @@ -326,7 +375,7 @@ export function openCodeCommandsToServerProviderSlashCommands(
if (!name || names.has(name) || command.source === "skill") continue;
names.add(name);
const description = trimOptional(command.description);
const hint = trimOptional(command.hints.join(" "));
const hint = trimOptional((command.hints ?? []).join(" "));
commands.push({
name,
...(description ? { description } : {}),
Expand Down Expand Up @@ -499,16 +548,55 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
readonly url: string;
readonly serverPassword?: string;
readonly version: string;
}) =>
openCodeRuntime
readonly apiVersion: OpenCodeApiVersion;
}) => {
const passwordOption =
server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {};
if (server.apiVersion === "v2") {
return openCodeRuntime
.loadOpenCodeInventoryV2(
openCodeRuntime.createOpenCodeV2Client({
baseUrl: server.url,
...passwordOption,
}),
cwd,
)
.pipe(
Effect.map((inventory) => ({
version: server.version,
models: providerModelsFromSettings(
flattenOpenCodeV2Models(inventory),
customModels,
DEFAULT_OPENCODE_MODEL_CAPABILITIES,
),
skills: openCodeSkillsToServerProviderSkills(inventory.skills),
slashCommands: openCodeCommandsToServerProviderSlashCommands(inventory.commands),
connectedCount: inventory.connectedProviders.length,
})),
);
}
return openCodeRuntime
.loadOpenCodeInventory(
openCodeRuntime.createOpenCodeSdkClient({
baseUrl: server.url,
directory: cwd,
...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}),
...passwordOption,
}),
)
.pipe(Effect.map((inventory) => ({ inventory, version: server.version })));
.pipe(
Effect.map((inventory) => ({
version: server.version,
models: providerModelsFromSettings(
flattenOpenCodeModels(inventory),
customModels,
DEFAULT_OPENCODE_MODEL_CAPABILITIES,
),
skills: openCodeSkillsToServerProviderSkills(inventory.skills),
slashCommands: openCodeCommandsToServerProviderSlashCommands(inventory.commands),
connectedCount: inventory.providerList.connected.length,
})),
);
};
const inventoryEffect = isExternalServer
? openCodeRuntime
.connectToOpenCodeServer({
Expand All @@ -534,22 +622,16 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu

version = inventoryExit.value.version;

const models = providerModelsFromSettings(
flattenOpenCodeModels(inventoryExit.value.inventory),
customModels,
DEFAULT_OPENCODE_MODEL_CAPABILITIES,
);
const skills = openCodeSkillsToServerProviderSkills(inventoryExit.value.inventory.skills);
const connectedCount = inventoryExit.value.inventory.providerList.connected.length;
const models = inventoryExit.value.models;
const skills = inventoryExit.value.skills;
const connectedCount = inventoryExit.value.connectedCount;
return buildServerProvider({
presentation: OPENCODE_PRESENTATION,
enabled: true,
checkedAt,
models,
skills,
slashCommands: openCodeCommandsToServerProviderSlashCommands(
inventoryExit.value.inventory.commands,
),
slashCommands: inventoryExit.value.slashCommands,
probe: {
installed: true,
version,
Expand Down
Loading
Loading