Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
5 changes: 5 additions & 0 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
</View>
) : null}

{selectedProviderStatus?.compatibilityAdvisory?.message ? (
<Text accessibilityRole="alert" className="px-3 py-2 text-xs text-foreground">
{selectedProviderStatus.compatibilityAdvisory.message}
</Text>
) : null}
{modelUnavailable ? (
<Pressable accessibilityRole="button" className="px-3 py-2" onPress={openSettings}>
<Text className="text-xs text-foreground">Model unavailable. Open model settings.</Text>
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/provider/Layers/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,13 @@ process.env.T3CODE_CURSOR_ENABLED = "1";
const encoder = new TextEncoder();
const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");

// Provider metadata checks use a bundled manifest and stubbed HTTP.
const TestHttpClientLive = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))),
),
);
).pipe(Layer.provideMerge(ModelManifest.layerTest));

const BackgroundPolicyAlwaysRunLayer = Layer.mock(BackgroundPolicy.BackgroundPolicy)({
reportClientActivity: () => Effect.void,
Expand Down
38 changes: 35 additions & 3 deletions apps/server/src/provider/Layers/ProviderRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import * as Ref from "effect/Ref";
import * as Stream from "effect/Stream";
import * as Semaphore from "effect/Semaphore";

import * as ModelManifest from "../ModelManifest.ts";
import { applyProviderCompatibility } from "../providerCompatibility.ts";
import { ServerConfig } from "../../config.ts";
import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts";
import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts";
Expand Down Expand Up @@ -278,6 +280,8 @@ export const ProviderRegistryLive = Layer.effect(
ProviderRegistry,
Effect.gen(function* () {
const instanceRegistry = yield* ProviderInstanceRegistry;
const manifestService = yield* ModelManifest.ModelManifest;
const serviceScope = yield* Effect.scope;
const config = yield* ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
Expand Down Expand Up @@ -356,7 +360,19 @@ export const ProviderRegistryLive = Layer.effect(
),
),
);
const providersRef = yield* Ref.make<ReadonlyArray<ServerProvider>>(cachedProviders);
const initialManifest = yield* manifestService.current;
const classifyCompatibility = (
provider: ServerProvider,
manifest: ModelManifest.ModelManifestData,
) =>
applyProviderCompatibility(
provider,
manifest.compatibility,
ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility,
);
const providersRef = yield* Ref.make<ReadonlyArray<ServerProvider>>(
cachedProviders.map((provider) => classifyCompatibility(provider, initialManifest)),
);
const workspaceRefreshesRef = yield* Ref.make<
ReadonlyMap<ProviderInstance, ReadonlySet<string>>
>(new Map());
Expand Down Expand Up @@ -424,6 +440,7 @@ export const ProviderRegistryLive = Layer.effect(
readonly replace?: boolean;
},
) {
const manifest = yield* manifestService.current;
const nextProvidersWithUpdateState = yield* Effect.forEach(
nextProviders,
applyProviderUpdateState,
Expand All @@ -450,7 +467,11 @@ export const ProviderRegistryLive = Layer.effect(
);
}

const providers = orderProviderSnapshots([...mergedProviders.values()]);
const providers = orderProviderSnapshots(
[...mergedProviders.values()].map((provider) =>
classifyCompatibility(provider, manifest),
),
);
const providersToPersist = providers.filter((provider) =>
updatedKeys.has(snapshotInstanceKey(provider)),
);
Expand All @@ -473,13 +494,24 @@ export const ProviderRegistryLive = Layer.effect(
return providers;
});

const compatibilityRefreshRunning = yield* Ref.make(false);
const syncProvider = Effect.fn("syncProvider")(function* (
provider: ServerProvider,
options?: {
readonly publish?: boolean;
},
) {
return yield* upsertProviders([provider], options);
const providers = yield* upsertProviders([provider], options);
// Reclassify the current read model after fetching. Never republish the
// probe captured before the fetch: a newer health result may have landed.
if (!(yield* Ref.getAndSet(compatibilityRefreshRunning, true))) {
yield* manifestService.refresh.pipe(
Effect.andThen(upsertProviders([], { persist: false })),
Effect.ensuring(Ref.set(compatibilityRefreshRunning, false)),
Effect.forkIn(serviceScope),
);
}
return providers;
});

const setProviderMaintenanceActionState = Effect.fn("setProviderMaintenanceActionState")(
Expand Down
40 changes: 40 additions & 0 deletions apps/server/src/provider/ModelManifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -549,3 +549,43 @@ describe("ModelManifest service", () => {
),
);
});

it.effect("caches valid compatibility policies and keeps them after a malformed refresh", () => {
const remote: ModelManifestData = {
...REMOTE_MANIFEST,
compatibility: [
{
driver: "codex",
t3CodeRange: ">=0.0.42",
recommendedVersion: "2.0.0",
ranges: [{ range: "=2.0.0", status: "supported" }],
},
],
};
let invalid = false;
return Effect.gen(function* () {
const service = yield* make;
assert.deepStrictEqual((yield* service.refresh).compatibility, remote.compatibility);
invalid = true;
yield* TestClock.adjust("1 hour");
assert.deepStrictEqual((yield* service.refresh).compatibility, remote.compatibility);
const rebooted = yield* make;
assert.deepStrictEqual((yield* rebooted.current).compatibility, remote.compatibility);
}).pipe(
Effect.scoped,
Effect.provide(
serviceLayers({
prefix: "model-manifest-compatibility-test",
response: () =>
Response.json(
invalid
? {
...remote,
compatibility: [{ ...remote.compatibility![0], recommendedVersion: "3.0.0" }],
}
: remote,
),
}),
),
);
});
2 changes: 2 additions & 0 deletions apps/server/src/provider/ModelManifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { ServerConfig } from "../config.ts";
import * as ServerSettings from "../serverSettings.ts";
import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts";
import bundledManifestJson from "./model-manifest.json" with { type: "json" };
import { ProviderCompatibilityPolicy } from "./providerCompatibility.ts";
import type { ServerProviderDraft } from "./providerSnapshot.ts";

const MODEL_MANIFEST_URL =
Expand Down Expand Up @@ -89,6 +90,7 @@ const ModelManifestEnvelopeSchema = Schema.Struct({
* files still decode; they count as older than any dated bundle.
*/
updatedAt: Schema.optional(Schema.String),
compatibility: Schema.optional(Schema.Array(ProviderCompatibilityPolicy)),
currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)),
providers: Schema.optional(Schema.Record(Schema.String, ManifestProviderCatalog)),
});
Expand Down
10 changes: 9 additions & 1 deletion apps/server/src/provider/model-manifest.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
{
"version": 1,
"updatedAt": "2026-09-22T19:42:55Z",
"updatedAt": "2026-09-22T21:40:00Z",
"compatibility": [
{
"driver": "opencode",
"t3CodeRange": ">=0.0.42",
"recommendedRange": ">=1.14.19",
"ranges": [{ "range": "<1.14.19", "status": "broken" }]
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
],
"currentModels": {
"codex": [
"gpt-6-astra",
Expand Down
Loading
Loading