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
35 changes: 34 additions & 1 deletion apps/server/src/provider/Layers/CodexProvider.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { assert, it } from "@effect/vitest";

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

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

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

it("keeps the app-server catalog by default and appends custom models", () => {
const models = resolveCodexProviderModels(
[{ slug: "gpt-native", name: "GPT Native", isCustom: false, capabilities: null }],
["proxy-only"],
"app-server",
);

assert.deepStrictEqual(
models.map(({ slug, isCustom }) => ({ slug, isCustom })),
[
{ slug: "gpt-native", isCustom: false },
{ slug: "proxy-only", isCustom: true },
],
);
});

it("restricts a proxy-backed instance to configured custom models", () => {
const models = resolveCodexProviderModels(
[{ slug: "gpt-native", name: "GPT Native", isCustom: false, capabilities: null }],
["proxy-only"],
"custom-only",
);

assert.deepStrictEqual(
models.map(({ slug, isCustom }) => ({ slug, isCustom })),
[{ slug: "proxy-only", isCustom: true }],
);
});
26 changes: 24 additions & 2 deletions apps/server/src/provider/Layers/CodexProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,19 @@ function appendCustomCodexModels(
return customEntries.length === 0 ? models : [...models, ...customEntries];
}

/**
* Scope the generic app-server catalog to the backend represented by this
* provider instance. Proxy-backed Codex instances can opt into a strict
* custom catalog when the app-server list is broader than the routed backend.
*/
export function resolveCodexProviderModels(
models: ReadonlyArray<ServerProviderModel>,
customModels: ReadonlyArray<CustomModelSetting>,
modelCatalogMode: CodexSettings["modelCatalogMode"],
): ReadonlyArray<ServerProviderModel> {
return appendCustomCodexModels(modelCatalogMode === "custom-only" ? [] : models, customModels);
}

function parseCodexSkillsListResponse(
response: CodexSchema.V2SkillsListResponse,
cwd: string,
Expand Down Expand Up @@ -415,6 +428,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
readonly launchArgs?: string;
readonly cwd: string;
readonly customModels?: ReadonlyArray<CustomModelSetting>;
readonly modelCatalogMode?: CodexSettings["modelCatalogMode"];
readonly environment?: NodeJS.ProcessEnv;
}) {
const { client, initialize } = yield* withCodexAppServerClient(input);
Expand All @@ -438,7 +452,9 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
client.request("skills/list", {
cwds: [input.cwd],
}),
requestAllCodexModels(client),
input.modelCatalogMode === "custom-only"
? Effect.succeed<ReadonlyArray<ServerProviderModel>>([])
: requestAllCodexModels(client),
// Usage is an enrichment: a failure or a slow answer degrades to "no
// usage this probe" rather than costing the account and models.
client.request("account/rateLimits/read", undefined).pipe(
Expand Down Expand Up @@ -468,7 +484,11 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
rateLimits,
version,
models: applyPreferredCodexDefaultModel(
appendCustomCodexModels(models, input.customModels ?? []),
resolveCodexProviderModels(
models,
input.customModels ?? [],
input.modelCatalogMode ?? "app-server",
),
),
skills: parseCodexSkillsListResponse(skillsResponse, input.cwd),
} satisfies CodexAppServerProviderSnapshot;
Expand Down Expand Up @@ -566,6 +586,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
readonly launchArgs?: string;
readonly cwd: string;
readonly customModels: ReadonlyArray<CustomModelSetting>;
readonly modelCatalogMode?: CodexSettings["modelCatalogMode"];
readonly environment?: NodeJS.ProcessEnv;
}) => Effect.Effect<
CodexAppServerProviderSnapshot,
Expand Down Expand Up @@ -605,6 +626,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
launchArgs: resolveCodexLaunchArgs(codexSettings.launchArgs, resolvedEnvironment),
cwd: process.cwd(),
customModels: codexSettings.customModels,
modelCatalogMode: codexSettings.modelCatalogMode,
environment: resolvedEnvironment,
}).pipe(
Effect.scoped,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ const makeCodexConfig = (overrides: Partial<CodexSettings>): CodexSettings => ({
binaryPath: "codex",
homePath: "",
shadowHomePath: "",
modelCatalogMode: "app-server",
launchArgs: "",
customModels: [],
...overrides,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/serverSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
binaryPath: "/opt/homebrew/bin/codex",
homePath: "/Users/julius/.codex",
shadowHomePath: "",
modelCatalogMode: "app-server",
launchArgs: "",
customModels: [],
});
Expand Down Expand Up @@ -938,6 +939,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
binaryPath: "/opt/homebrew/bin/codex",
homePath: "",
shadowHomePath: "",
modelCatalogMode: "app-server",
launchArgs: "",
customModels: [],
});
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/components/settings/ProviderSettingsForm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,20 @@ describe("ProviderSettingsForm helpers", () => {
"binaryPath",
"homePath",
"shadowHomePath",
"modelCatalogMode",
"launchArgs",
]);

expect(
deriveProviderSettingsFields(codex!).find((field) => field.key === "modelCatalogMode"),
).toMatchObject({
control: "select",
clearWhenEmpty: "omit",
options: [
{ value: "app-server", label: "Codex app-server" },
{ value: "custom-only", label: "Custom models only" },
],
});
});

it("sources labels and descriptions from schema annotations", () => {
Expand Down
7 changes: 7 additions & 0 deletions docs/user/providers-codex.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
# Codex

For one account, use the default Codex provider with your normal Codex login.

Codex normally offers every model reported by its app-server. If an instance
routes through a proxy with a narrower catalog, set **Model catalog** to
**Custom models only** and add only the model IDs that backend can serve. This
prevents the generic app-server catalog from advertising models on the wrong
route; it does not verify account or backend entitlement.

[Provider setup](./install.md#providers) covers installation, Settings > Providers,
and custom binaries or environment variables.

Expand Down
16 changes: 16 additions & 0 deletions packages/contracts/src/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
ClientSettingsSchema,
ClientSettingsPatch,
ClaudeSettings,
CodexSettings,
DEFAULT_SERVER_SETTINGS,
resolveProviderInstanceEnabled,
ServerSettings,
Expand All @@ -19,6 +20,7 @@ const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings);
const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);
const encodeServerSettings = Schema.encodeSync(ServerSettings);
const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings);
const decodeCodexSettings = Schema.decodeUnknownSync(CodexSettings);

describe("storage cleanup settings", () => {
it("keeps cleanup disabled for existing installations", () => {
Expand Down Expand Up @@ -190,6 +192,20 @@ describe("custom model settings", () => {
decodeServerSettingsPatch({ providers: { codex: { customModels: [{ name: "no slug" }] } } }),
).toThrow();
});

it("supports an instance-scoped Codex catalog mode", () => {
expect(decodeCodexSettings({}).modelCatalogMode).toBe("app-server");
expect(decodeCodexSettings({ modelCatalogMode: "custom-only" }).modelCatalogMode).toBe(
"custom-only",
);
expect(
decodeServerSettingsPatch({ providers: { codex: { modelCatalogMode: "custom-only" } } })
.providers?.codex?.modelCatalogMode,
).toBe("custom-only");
expect(() =>
decodeServerSettingsPatch({ providers: { codex: { modelCatalogMode: "unknown" } } }),
).toThrow();
});
});

describe("ClaudeSettings auto-compaction", () => {
Expand Down
22 changes: 21 additions & 1 deletion packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,9 @@ function makeProviderSettingsSchema<const Fields extends Schema.Struct.Fields>(
);
}

export const CodexModelCatalogMode = Schema.Literals(["app-server", "custom-only"]);
export type CodexModelCatalogMode = typeof CodexModelCatalogMode.Type;

export const CodexSettings = makeProviderSettingsSchema(
{
enabled: Schema.Boolean.pipe(
Expand Down Expand Up @@ -603,6 +606,22 @@ export const CodexSettings = makeProviderSettingsSchema(
},
}),
),
modelCatalogMode: CodexModelCatalogMode.pipe(
Schema.withDecodingDefault(Effect.succeed("app-server" as const)),
Schema.annotateKey({
title: "Model catalog",
description:
"Use the Codex app-server catalog, or restrict this instance to its configured custom models.",
providerSettingsForm: {
control: "select",
clearWhenEmpty: "omit",
options: [
{ value: "app-server", label: "Codex app-server" },
{ value: "custom-only", label: "Custom models only" },
],
},
}),
),
launchArgs: TrimmedString.pipe(
Schema.withDecodingDefault(Effect.succeed("")),
Schema.annotateKey({
Expand All @@ -616,7 +635,7 @@ export const CodexSettings = makeProviderSettingsSchema(
),
},
{
order: ["binaryPath", "homePath", "shadowHomePath", "launchArgs"],
order: ["binaryPath", "homePath", "shadowHomePath", "modelCatalogMode", "launchArgs"],
},
);
export type CodexSettings = typeof CodexSettings.Type;
Expand Down Expand Up @@ -1395,6 +1414,7 @@ const CodexSettingsPatch = Schema.Struct({
binaryPath: Schema.optionalKey(TrimmedString),
homePath: Schema.optionalKey(TrimmedString),
shadowHomePath: Schema.optionalKey(TrimmedString),
modelCatalogMode: Schema.optionalKey(CodexModelCatalogMode),
launchArgs: Schema.optionalKey(TrimmedString),
customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)),
});
Expand Down
Loading