Skip to content
Open
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
29 changes: 29 additions & 0 deletions apps/server/src/provider/Layers/CodexAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,35 @@ validationLayer("CodexAdapterLive validation", (it) => {
NodeAssert.equal(validationRuntimeFactory.factory.mock.calls.length, 0);
}),
);
it.effect("rejects an undeclared provider-namespaced model before starting Codex", () =>
Effect.gen(function* () {
validationRuntimeFactory.factory.mockClear();
const adapter = yield* CodexAdapter;
const result = yield* adapter
.startSession({
provider: ProviderDriverKind.make("codex"),
threadId: asThreadId("thread-namespaced-model"),
modelSelection: createModelSelection(
ProviderInstanceId.make("codex"),
"z-ai/glm-5.3-flash",
),
runtimeMode: "full-access",
})
.pipe(Effect.result);

NodeAssert.equal(result._tag, "Failure");
NodeAssert.deepStrictEqual(
result.failure,
new ProviderAdapterValidationError({
provider: ProviderDriverKind.make("codex"),
operation: "startSession",
issue:
"Provider-namespaced model 'z-ai/glm-5.3-flash' is not configured on instance 'codex'.",
}),
);
NodeAssert.equal(validationRuntimeFactory.factory.mock.calls.length, 0);
}),
);
it.effect("maps codex model options before starting a session", () =>
Effect.gen(function* () {
validationRuntimeFactory.factory.mockClear();
Expand Down
30 changes: 30 additions & 0 deletions apps/server/src/provider/Layers/CodexAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type CanonicalItemType,
type CanonicalRequestType,
type CodexSettings,
type ModelSelection,
ProviderDriverKind,
type ProviderEvent,
ProviderInstanceId,
Expand Down Expand Up @@ -54,6 +55,7 @@ import {
import { type CodexAdapterShape } from "../Services/CodexAdapter.ts";
import { resolveAttachmentPath } from "../../attachmentStore.ts";
import { ServerConfig } from "../../config.ts";
import { isProviderNamespacedModelSlug } from "../providerSnapshot.ts";
import {
CodexResumeCursorSchema,
CodexSessionRuntimeThreadIdMissingError,
Expand Down Expand Up @@ -1662,6 +1664,24 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
options?.nativeEventLogger === undefined ? nativeEventLogger : undefined;
const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>();
const sessions = new Map<ThreadId, CodexAdapterSessionContext>();
const customModelSlugs = new Set(codexConfig.customModels.map((model) => model.trim()));
const validateModelSelection = (
operation: "startSession" | "sendTurn",
selection: ModelSelection | undefined,
): ProviderAdapterValidationError | undefined => {
if (
selection?.instanceId !== boundInstanceId ||
!isProviderNamespacedModelSlug(selection.model) ||
customModelSlugs.has(selection.model)
) {
return undefined;
}
return new ProviderAdapterValidationError({
provider: PROVIDER,
operation,
issue: `Provider-namespaced model '${selection.model}' is not configured on instance '${boundInstanceId}'.`,
});
};

const startSession: CodexAdapterShape["startSession"] = (input) =>
Effect.scoped(
Expand All @@ -1674,6 +1694,11 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
});
}

const modelSelectionError = validateModelSelection("startSession", input.modelSelection);
if (modelSelectionError) {
return yield* modelSelectionError;
}

const existing = sessions.get(input.threadId);
if (existing && !existing.stopped) {
yield* Effect.suspend(() => stopSessionInternal(existing));
Expand Down Expand Up @@ -1822,6 +1847,11 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
});

const sendTurn: CodexAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) {
const modelSelectionError = validateModelSelection("sendTurn", input.modelSelection);
if (modelSelectionError) {
return yield* modelSelectionError;
}

// Codex ingests images only. Anything else would be base64-encoded as an
// image and rejected or misread; generic files reach the agent through the
// path line ProviderService puts in the prompt.
Expand Down
77 changes: 76 additions & 1 deletion apps/server/src/provider/Layers/CodexProvider.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { assert, it } from "@effect/vitest";
import type { ServerProviderModel } from "@t3tools/contracts";

import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts";
import {
applyPreferredCodexDefaultModel,
mapCodexModelCapabilities,
scopeCodexModelsToInstance,
} from "./CodexProvider.ts";

it("maps current Codex model capability fields", () => {
const capabilities = mapCodexModelCapabilities({
Expand Down Expand Up @@ -144,3 +149,73 @@ it("ignores custom models that shadow a preferred slug", () => {

assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4");
});

it("requires provider-namespaced catalog models to be declared on the instance", () => {
const openAiModel: ServerProviderModel = {
slug: "gpt-5.6-sol",
name: "GPT-5.6-Sol",
isCustom: false,
capabilities: null,
};
const openRouterModel: ServerProviderModel = {
slug: "z-ai/glm-5.3-flash",
name: "GLM-5.3-Flash",
isCustom: false,
capabilities: { optionDescriptors: [] },
};
const catalog = [openAiModel, openRouterModel];

assert.deepStrictEqual(
scopeCodexModelsToInstance(catalog, []).map((model) => model.slug),
["gpt-5.6-sol"],
);
assert.deepStrictEqual(scopeCodexModelsToInstance(catalog, ["z-ai/glm-5.3-flash"]), [
openAiModel,
{
...openRouterModel,
isCustom: true,
},
]);
});

it("does not infer capabilities for custom models absent from the catalog", () => {
const catalogModel: ServerProviderModel = {
slug: "gpt-5.6-sol",
name: "GPT-5.6-Sol",
isCustom: false,
capabilities: {
optionDescriptors: [
{
id: "reasoningEffort",
label: "Reasoning",
type: "select",
options: [{ id: "xhigh", label: "xhigh" }],
},
],
},
};

assert.deepStrictEqual(scopeCodexModelsToInstance([catalogModel], ["z-ai/glm-5.3-flash"]), [
catalogModel,
{
slug: "z-ai/glm-5.3-flash",
name: "z-ai/glm-5.3-flash",
isCustom: true,
capabilities: null,
},
]);
});

it("preserves first-party catalog metadata when a custom slug shadows it", () => {
const firstPartyModel: ServerProviderModel = {
slug: "gpt-5.6-sol",
name: "GPT-5.6-Sol",
isCustom: false,
isLegacy: true,
capabilities: null,
};

assert.deepStrictEqual(scopeCodexModelsToInstance([firstPartyModel], [firstPartyModel.slug]), [
firstPartyModel,
]);
});
45 changes: 31 additions & 14 deletions apps/server/src/provider/Layers/CodexProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts
import {
AUTH_PROBE_TIMEOUT_MS,
buildServerProvider,
isProviderNamespacedModelSlug,
type ServerProviderDraft,
} from "../providerSnapshot.ts";
import { expandHomePath } from "../../pathExpansion.ts";
Expand Down Expand Up @@ -224,31 +225,47 @@ export function applyPreferredCodexDefaultModel(
});
}

function appendCustomCodexModels(
/**
* Codex stores one model catalog cache per home, even when that home is used
* with multiple model providers. A provider-namespaced slug from that cache is
* therefore not evidence that the current instance can run it. Require an
* explicit custom-model declaration on this instance before exposing it.
*/
export function scopeCodexModelsToInstance(
models: ReadonlyArray<ServerProviderModel>,
customModels: ReadonlyArray<string>,
): ReadonlyArray<ServerProviderModel> {
if (customModels.length === 0) {
return models;
const customSlugs = new Set(customModels.map((model) => model.trim()).filter(Boolean));
const scopedModels: ServerProviderModel[] = [];
const seen = new Set<string>();

for (const model of models) {
if (isProviderNamespacedModelSlug(model.slug)) {
if (customSlugs.has(model.slug)) {
const { isLegacy: _isLegacy, ...rest } = model;
scopedModels.push({ ...rest, isCustom: true });
seen.add(model.slug);
}
continue;
}

scopedModels.push(model);
seen.add(model.slug);
}

const seen = new Set(models.map((model) => model.slug));
const fallbackCapabilities = models.find((model) => model.capabilities)?.capabilities ?? null;
const customEntries: ServerProviderModel[] = [];
for (const rawModel of customModels) {
const slug = rawModel.trim();
if (!slug || seen.has(slug)) {
for (const slug of customSlugs) {
if (seen.has(slug)) {
continue;
}
seen.add(slug);
customEntries.push({
scopedModels.push({
slug,
name: slug,
isCustom: true,
capabilities: fallbackCapabilities,
capabilities: null,
});
}
return customEntries.length === 0 ? models : [...models, ...customEntries];
return scopedModels;
}

function parseCodexSkillsListResponse(
Expand Down Expand Up @@ -389,7 +406,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
return {
account: accountResponse,
version,
models: appendCustomCodexModels([], input.customModels ?? []),
models: scopeCodexModelsToInstance([], input.customModels ?? []),
skills: [],
} satisfies CodexAppServerProviderSnapshot;
}
Expand All @@ -408,7 +425,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
account: accountResponse,
version,
models: applyPreferredCodexDefaultModel(
appendCustomCodexModels(models, input.customModels ?? []),
scopeCodexModelsToInstance(models, input.customModels ?? []),
),
skills: parseCodexSkillsListResponse(skillsResponse, input.cwd),
} satisfies CodexAppServerProviderSnapshot;
Expand Down
37 changes: 37 additions & 0 deletions apps/server/src/provider/Layers/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,43 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
]);
});

it("drops a removed provider-namespaced Codex custom model", () => {
const firstPartyModel = {
slug: "gpt-5.6-sol",
name: "GPT-5.6-Sol",
isCustom: false,
capabilities: codexModelCapabilities,
} as const;
const removedCustomModel = {
slug: "z-ai/glm-5.3-flash",
name: "GLM-5.3-Flash",
isCustom: true,
capabilities: codexModelCapabilities,
} as const;
const previousProvider = {
instanceId: ProviderInstanceId.make("codex"),
driver: ProviderDriverKind.make("codex"),
status: "ready",
enabled: true,
installed: true,
auth: { status: "authenticated" },
checkedAt: "2026-08-28T00:00:00.000Z",
version: "1.0.0",
models: [firstPartyModel, removedCustomModel],
slashCommands: [],
skills: [],
} as const satisfies ServerProvider;
const refreshedProvider = {
...previousProvider,
checkedAt: "2026-08-28T00:01:00.000Z",
models: [],
} satisfies ServerProvider;

assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [
firstPartyModel,
]);
});

it("drops stale OpenCode models missing from a successful refresh", () => {
const previousProvider = {
instanceId: ProviderInstanceId.make("opencode"),
Expand Down
10 changes: 8 additions & 2 deletions apps/server/src/provider/Layers/ProviderRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
} from "../providerStatusCache.ts";
import type { ProviderInstance } from "../ProviderDriver.ts";
import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
import { isProviderNamespacedModelSlug } from "../providerSnapshot.ts";
import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts";

const loadProviders = (
Expand Down Expand Up @@ -101,9 +102,14 @@ const mergeProviderModels = (
nextModels: ReadonlyArray<ServerProvider["models"][number]>,
): ReadonlyArray<ServerProvider["models"][number]> => {
const shouldRetainMissingModels = shouldRetainMissingProviderModels(provider);
const retainablePreviousModels = previousModels.filter(
(model) =>
provider.driver !== ProviderDriverKind.make("codex") ||
!isProviderNamespacedModelSlug(model.slug),
);

if (shouldRetainMissingModels && nextModels.length === 0 && previousModels.length > 0) {
return previousModels;
return retainablePreviousModels;
}

const previousBySlug = new Map(previousModels.map((model) => [model.slug, model] as const));
Expand All @@ -119,7 +125,7 @@ const mergeProviderModels = (
});
const nextSlugs = new Set(nextModels.map((model) => model.slug));
return shouldRetainMissingModels
? [...mergedModels, ...previousModels.filter((model) => !nextSlugs.has(model.slug))]
? [...mergedModels, ...retainablePreviousModels.filter((model) => !nextSlugs.has(model.slug))]
: mergedModels;
};

Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/provider/providerSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ export function nonEmptyTrimmed(value: string | undefined): string | undefined {
return trimmed.length > 0 ? trimmed : undefined;
}

export function isProviderNamespacedModelSlug(slug: string): boolean {
return slug.includes("/");
}

export function isCommandMissingCause(error: unknown): boolean {
if (isProviderCommandNotFoundError(error)) return true;
return error instanceof PlatformError.PlatformError && error.reason._tag === "NotFound";
Expand Down
27 changes: 27 additions & 0 deletions apps/server/src/provider/providerStatusCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,33 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => {
);
});

it("does not hydrate undeclared provider-namespaced Codex models", () => {
const namespacedModel = {
slug: "z-ai/glm-5.3-flash",
name: "GLM-5.3-Flash",
isCustom: false,
capabilities: emptyCapabilities,
} as const;
const cachedCodex = makeProvider(CODEX_DRIVER, { models: [namespacedModel] });
const fallbackCodex = makeProvider(CODEX_DRIVER);

assert.deepStrictEqual(
hydrateCachedProvider({ cachedProvider: cachedCodex, fallbackProvider: fallbackCodex })
.models,
[],
);

const declaredModel = { ...namespacedModel, isCustom: true } as const;
const declaredFallback = makeProvider(CODEX_DRIVER, { models: [declaredModel] });
assert.deepStrictEqual(
hydrateCachedProvider({
cachedProvider: cachedCodex,
fallbackProvider: declaredFallback,
}).models,
[declaredModel],
);
});

it("rejects cached snapshots that are not correlated to the fallback instance", () => {
const fallbackCodex = makeProvider(CODEX_DRIVER, {
models: [
Expand Down
Loading
Loading