Skip to content
Merged
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
13 changes: 13 additions & 0 deletions packages/provider-opencode/src/server/openCode2Catalog.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as TestClock from "effect/testing/TestClock";
import { HttpClient, HttpClientResponse } from "effect/http";
import { describe } from "vite-plus/test";

Expand Down Expand Up @@ -84,6 +86,17 @@ describe("loadOpenCode2Catalog", () => {
}),
);

it.effect("lists no models yet when the model list does not answer", () =>
Effect.gen(function* () {
const fiber = yield* Effect.forkChild(
readCatalog({ "/api/provider": PROVIDERS_BODY }, ["/api/model"]),
);
yield* TestClock.adjust("5 seconds");

assert.deepStrictEqual(yield* Fiber.join(fiber), []);
}),
);

// regression: #17424 only the provider list is optional; a model-list failure fails the read
it.effect("fails the read when the model list fails", () =>
Effect.gen(function* () {
Expand Down
12 changes: 10 additions & 2 deletions packages/provider-opencode/src/server/openCode2Catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import type { OpenCode2Model } from "./status.ts";

// Cold catalogs take seconds; model reads already retry empty catalogs.
const PROVIDER_NAME_TIMEOUT = "3 seconds";
// Bounds the request alone (server start has its own bounds); a slow list reads as not listed yet.
const MODEL_LIST_TIMEOUT = "5 seconds";

const listProviders = (client: OpenCodeClient, directory: string) =>
client.provider.list({ location: { directory } }).pipe(
Expand All @@ -19,12 +21,18 @@ const readCatalog = <E>(
providers: Effect.Effect<ReadonlyArray<{ readonly id: string; readonly name: string }>, E>,
): Effect.Effect<ReadonlyArray<OpenCode2Model>, OpenCodeRuntime.OpenCodeRuntimeError> =>
Effect.all(
{ models: client.model.list({ location: { directory } }), providers },
{
models: client.model.list({ location: { directory } }).pipe(
Effect.timeoutOption(MODEL_LIST_TIMEOUT),
Effect.map((list) => (list._tag === "Some" ? list.value.data : [])),
),
providers,
},
{ concurrency: "unbounded" },
).pipe(
Effect.map(({ models, providers }) => {
const providerNames = new Map(providers.map((provider) => [provider.id, provider.name]));
return models.data.map((model) => {
return models.map((model) => {
const providerName = providerNames.get(model.providerID);
return {
providerID: model.providerID,
Expand Down
19 changes: 19 additions & 0 deletions packages/provider-opencode/src/server/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import * as Layer from "effect/Layer";
import * as Logger from "effect/Logger";
import * as Schema from "effect/Schema";
import * as Tracer from "effect/Tracer";
import * as TestClock from "effect/testing/TestClock";
import * as HttpClient from "effect/http/HttpClient";
import * as HttpClientResponse from "effect/http/HttpClientResponse";
import { beforeEach } from "vite-plus/test";
Expand All @@ -24,6 +25,7 @@ import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts";
import {
checkOpenCodeProviderStatus,
loadOpenCode2Workspace,
makeOpenCode2ModelLoader,
type OpenCode2Model,
type OpenCode2Workspace,
} from "./status.ts";
Expand Down Expand Up @@ -1160,3 +1162,20 @@ it.effect("reads an already loaded OpenCode 2 directory once without waiting for
NodeAssert.deepEqual(calls, ["read"]);
}),
);

it.effect("lists a catalog whose cold server start outlasts the settle budget", () =>
Effect.gen(function* () {
const catalog: ReadonlyArray<OpenCode2Model> = [
{ providerID: "opencode", id: "big-pickle", name: "Big Pickle", variants: [] },
];
let reads = 0;
// The loader's read includes spawning the server; a loaded host can take 6s to start it.
const read = Effect.suspend(() =>
reads++ === 0 ? Effect.sleep("6 seconds").pipe(Effect.as(catalog)) : Effect.succeed(catalog),
);
const load = yield* makeOpenCode2ModelLoader(read);
const fiber = yield* Effect.forkChild(load);
yield* TestClock.adjust("10 seconds");
NodeAssert.deepEqual(yield* Fiber.join(fiber), catalog);
}),
);
33 changes: 21 additions & 12 deletions packages/provider-opencode/src/server/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,10 +498,13 @@ const catalogFingerprint = (models: ReadonlyArray<OpenCode2Model>) =>
.join("\n");

/** A cold server can list a partial catalog; two agreeing reads reduce that startup race. */
const settleOpenCode2Catalog = <E>(list: Effect.Effect<ReadonlyArray<OpenCode2Model>, E>) =>
const settleOpenCode2Catalog = <E>(
list: Effect.Effect<ReadonlyArray<OpenCode2Model>, E>,
first?: ReadonlyArray<OpenCode2Model>,
) =>
Effect.suspend(() => {
let previous: string | undefined;
return list.pipe(
let previous = first && first.length > 0 ? catalogFingerprint(first) : undefined;
return (first ? Effect.delay(list, "250 millis") : list).pipe(
Effect.repeat({
until: (models) => {
const fingerprint = models.length > 0 ? catalogFingerprint(models) : undefined;
Expand All @@ -523,17 +526,23 @@ export const makeOpenCode2ModelLoader = <E>(
let lastLoaded: ReadonlyArray<OpenCode2Model> = [];
return Effect.suspend(() => {
let latest: ReadonlyArray<OpenCode2Model> = [];
return settleOpenCode2Catalog(
list.pipe(
Effect.tap((models) =>
Effect.sync(() => {
if (models.length > 0) latest = models;
}),
const tracked = list.pipe(
Effect.tap((models) =>
Effect.sync(() => {
if (models.length > 0) latest = models;
}),
),
);
// The first read can include a cold server start, so only settling shares the budget.
return tracked.pipe(
Effect.flatMap((first) =>
settleOpenCode2Catalog(tracked, first).pipe(
Effect.timeoutOption(OPENCODE_2_CATALOG_SETTLE_TIMEOUT),
),
),
).pipe(
Effect.catch((error) => (latest.length > 0 ? Effect.succeed(latest) : Effect.fail(error))),
Effect.timeoutOption(OPENCODE_2_CATALOG_SETTLE_TIMEOUT),
Effect.catch((error) =>
latest.length > 0 ? Effect.succeedSome(latest) : Effect.fail(error),
),
Effect.map((loaded) => {
// Fresh progress may still be partial, but a warm cache must not freeze slow catalogs.
if (loaded._tag === "Some") lastLoaded = loaded.value;
Expand Down
Loading