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
40 changes: 30 additions & 10 deletions apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { LegendListRenderItemProps } from "@legendapp/list/react-native";
import { useAtomSet, useAtomValue } from "@effect/atom-react";
import { AnimatedLegendList } from "@legendapp/list/reanimated";
import {
carryCodexCyberAccessProgram,
getProviderOptionCurrentLabel,
getProviderOptionCurrentValue,
getProviderOptionDescriptors,
Expand Down Expand Up @@ -433,15 +434,34 @@ function ThreadSettingsSessionProvider(
const pressModel = useCallback(
(option: ModelOption) => {
void Haptics.selectionAsync();
setPendingModel((current) =>
pendingModelAfterPress({
current,
pressed: option,
pressedIsApplied: isApplied(option),
}),
);
const pending = pendingModelAfterPress({
current: pendingModel,
pressed: option,
pressedIsApplied: isApplied(option),
});
if (!pending) {
setPendingModel(null);
return;
}
const { selection, didReset } = carryCodexCyberAccessProgram({
current: pendingModel?.selection ?? props.selectedModel,
next: pending.selection,
nextCapabilities: pending.capabilities,
});
if (didReset) {
Alert.alert(
"Switch models and reset Daybreak?",
"The selected model does not support your current Daybreak setting.",
[
{ text: "Cancel", style: "cancel" },
{ text: "OK", onPress: () => setPendingModel({ ...pending, selection }) },
],
);
return;
}
setPendingModel({ ...pending, selection });
},
[isApplied],
[isApplied, pendingModel, props.selectedModel],
);

const value = useMemo<ThreadSettingsSessionValue>(
Expand Down Expand Up @@ -1342,10 +1362,10 @@ export function ExistingThreadSettingsRouteScreen() {
return <View className="flex-1 bg-sheet" />;
}

const { ownerId: _ownerId, ...settings } = session;
const { ownerId, ...settings } = session;

return (
<ThreadSettingsSessionProvider {...settings}>
<ThreadSettingsSessionProvider key={ownerId} {...settings}>
<ThreadSettingsPickerNavigator onClose={() => navigation.goBack()} />
</ThreadSettingsSessionProvider>
);
Expand Down
2 changes: 2 additions & 0 deletions apps/server/integration/providerService.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import * as Stream from "effect/Stream";

import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts";
import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts";
import { makeProviderRegistryLayer } from "../src/provider/testUtils/providerRegistryMock.ts";
import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts";
import {
NoOpProviderEventLoggers,
Expand Down Expand Up @@ -94,6 +95,7 @@ const makeIntegrationFixture = (options?: { readonly analytics?: Layer.Layer<Ana
const shared = Layer.mergeAll(
directoryLayer,
Layer.succeed(ProviderAdapterRegistry, registry),
makeProviderRegistryLayer(),
ServerConfig.layerTest(cwd, cwd).pipe(Layer.provide(NodeServices.layer)),
ServerSettingsService.layerTest(DEFAULT_SERVER_SETTINGS),
options?.analytics ?? AnalyticsService.layerTest,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/provider/Layers/CodexAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {
modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [
{ id: "reasoningEffort", value: "high" },
{ id: "serviceTier", value: "priority" },
{ id: "cyberAccessProgram", value: "daybreakRed" },
]),
attachments: [],
}),
Expand All @@ -444,6 +445,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {
model: "gpt-5.3-codex",
effort: "high",
serviceTier: "priority",
cyberAccessProgram: "daybreakRed",
});
}),
);
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/provider/Layers/CodexAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2539,6 +2539,16 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
input.modelSelection?.instanceId === boundInstanceId
? getCodexServiceTierOptionValue(input.modelSelection)
: undefined;
const selectedCyberAccessProgram =
input.modelSelection?.instanceId === boundInstanceId
? getModelSelectionStringOptionValue(input.modelSelection, "cyberAccessProgram")
: undefined;
const cyberAccessProgram =
selectedCyberAccessProgram === "standard" ||
selectedCyberAccessProgram === "daybreakBlue" ||
selectedCyberAccessProgram === "daybreakRed"
? selectedCyberAccessProgram
: undefined;
return yield* session.runtime
.sendTurn({
...(input.input !== undefined ? { input: input.input } : {}),
Expand All @@ -2551,6 +2561,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
}
: {}),
...(serviceTier ? { serviceTier } : {}),
...(cyberAccessProgram ? { cyberAccessProgram } : {}),
...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),
...(codexAttachments.length > 0 ? { attachments: codexAttachments } : {}),
})
Expand Down
109 changes: 108 additions & 1 deletion apps/server/src/provider/Layers/CodexProvider.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,113 @@
import { assert, it } from "@effect/vitest";
import * as Schema from "effect/Schema";

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

const modelForAccessTest = {
additionalSpeedTiers: [],
defaultReasoningEffort: "high",
description: "Test model",
displayName: "GPT Test",
hidden: false,
id: "gpt-test",
isDefault: true,
model: "gpt-test",
supportedReasoningEfforts: [{ description: "High", reasoningEffort: "high" }],
};
const decodeCodexModelListWithAccessPrograms = Schema.decodeUnknownSync(
CodexModelListWithAccessPrograms,
);

it("keeps caller-specific access programs from model discovery", () => {
const response = decodeCodexModelListWithAccessPrograms({
data: [
{
...modelForAccessTest,
availableAccessPrograms: { cyber: ["standard", "daybreakBlue", "daybreakRed"] },
},
],
nextCursor: null,
});
assert.deepStrictEqual(mapCodexModelCapabilities(response.data[0]!).optionDescriptors?.at(-1), {
id: "cyberAccessProgram",
label: "Daybreak",
type: "select",
options: [
{ id: "standard", label: "Off", isDefault: true },
{ id: "daybreakRed", label: "Red" },
{ id: "daybreakBlue", label: "Blue" },
],
currentValue: "standard",
});
});

it("uses On and Off when the account has one Daybreak program", () => {
for (const program of ["daybreakBlue", "daybreakRed"]) {
const daybreak = mapCodexModelCapabilities({
...modelForAccessTest,
availableAccessPrograms: { cyber: ["standard", program] },
}).optionDescriptors?.find((descriptor) => descriptor.id === "cyberAccessProgram");
assert.deepStrictEqual(daybreak?.type === "select" ? daybreak.options : [], [
{ id: "standard", label: "Off", isDefault: true },
{ id: program, label: "On" },
]);
}
});

it("hides Daybreak when access is absent for the account or model", () => {
for (const model of [
modelForAccessTest,
{
...modelForAccessTest,
model: "gpt-6-astra",
availableAccessPrograms: { cyber: ["standard"] },
},
{
...modelForAccessTest,
model: "experimental-red-only-alias",
availableAccessPrograms: { cyber: ["daybreakRed"] },
},
]) {
assert.equal(
mapCodexModelCapabilities(model).optionDescriptors?.some(
(descriptor) => descriptor.id === "cyberAccessProgram",
),
false,
);
}
});

it("does not give unverified custom models a built-in model's Daybreak access", () => {
const builtInCapabilities = mapCodexModelCapabilities({
...modelForAccessTest,
availableAccessPrograms: { cyber: ["standard", "daybreakBlue"] },
});
const custom = appendCustomCodexModels(
[{ slug: "gpt-test", name: "GPT Test", isCustom: false, capabilities: builtInCapabilities }],
["custom-model"],
);
assert.equal(
custom[1]?.capabilities?.optionDescriptors?.some(
(descriptor) => descriptor.id === "cyberAccessProgram",
),
false,
);
const explicitlyConfigured = appendCustomCodexModels(
[{ slug: "gpt-test", name: "GPT Test", isCustom: false, capabilities: builtInCapabilities }],
[{ slug: "custom-model", capabilities: builtInCapabilities }],
);
assert.equal(
explicitlyConfigured[1]?.capabilities?.optionDescriptors?.some(
(descriptor) => descriptor.id === "cyberAccessProgram",
),
false,
);
});

it("maps current Codex model capability fields", () => {
const capabilities = mapCodexModelCapabilities({
Expand Down
79 changes: 68 additions & 11 deletions apps/server/src/provider/Layers/CodexProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,20 @@ const REASONING_EFFORT_LABELS: Readonly<Record<string, string>> = {
};

const DEFAULT_SERVICE_TIER_ID = "default";
const CodexModelWithAccessPrograms = CodexSchema.V2ModelListResponse__Model.pipe(
Schema.fieldsAssign({
availableAccessPrograms: Schema.optionalKey(
Schema.NullOr(Schema.Struct({ cyber: Schema.optionalKey(Schema.Array(Schema.String)) })),
),
}),
);
export const CodexModelListWithAccessPrograms = CodexSchema.V2ModelListResponse.pipe(
Schema.fieldsAssign({ data: Schema.Array(CodexModelWithAccessPrograms) }),
);
const decodeCodexModelListWithAccessPrograms = Schema.decodeUnknownEffect(
CodexModelListWithAccessPrograms,
);
type CodexModelWithAccessPrograms = typeof CodexModelWithAccessPrograms.Type;

function reasoningEffortLabel(reasoningEffort: string): string {
return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort;
Expand Down Expand Up @@ -142,9 +156,7 @@ function codexAccountEmail(account: CodexSchema.V2GetAccountResponse["account"])
return account.email;
}

export function mapCodexModelCapabilities(
model: CodexSchema.V2ModelListResponse__Model,
): ModelCapabilities {
export function mapCodexModelCapabilities(model: CodexModelWithAccessPrograms): ModelCapabilities {
const reasoningOptions = model.supportedReasoningEfforts.map(({ reasoningEffort }) =>
reasoningEffort ===
(codexModelFamily(model.model) === "gpt-6-astra" ? "medium" : model.defaultReasoningEffort)
Expand Down Expand Up @@ -206,20 +218,41 @@ export function mapCodexModelCapabilities(
});
}

const accessPrograms = model.availableAccessPrograms?.cyber ?? [];
const daybreakPrograms = ["daybreakRed", "daybreakBlue"].filter((program) =>
accessPrograms.includes(program),
);
// Off must be a real explicit program, not an omission that Codex can route automatically.
if (accessPrograms.includes("standard") && daybreakPrograms.length > 0) {
optionDescriptors.push({
id: "cyberAccessProgram",
label: "Daybreak",
type: "select",
options: [
{ id: "standard", label: "Off", isDefault: true },
...daybreakPrograms.map((program) => ({
id: program,
label: daybreakPrograms.length === 1 ? "On" : program === "daybreakBlue" ? "Blue" : "Red",
})),
],
currentValue: "standard",
});
}

return createModelCapabilities({
optionDescriptors,
});
}

const toDisplayName = (model: CodexSchema.V2ModelListResponse__Model): string => {
const toDisplayName = (model: CodexModelWithAccessPrograms): string => {
// Capitalize 'gpt' to 'GPT-' and capitalize any letter following a dash
return model.displayName
.replace(/^gpt/i, "GPT") // Handle start with 'gpt' or 'GPT'
.replace(/-([a-z])/g, (_, c) => "-" + c.toUpperCase());
};

function parseCodexModelListResponse(
response: CodexSchema.V2ModelListResponse,
response: typeof CodexModelListWithAccessPrograms.Type,
): ReadonlyArray<ServerProviderModel> {
return response.data.map((model) => ({
slug: model.model,
Expand Down Expand Up @@ -260,7 +293,7 @@ export function applyPreferredCodexDefaultModel(
* the first built-in's descriptors; an entry with its own capabilities keeps
* them.
*/
function appendCustomCodexModels(
export function appendCustomCodexModels(
models: ReadonlyArray<ServerProviderModel>,
customModels: ReadonlyArray<CustomModelSetting>,
): ReadonlyArray<ServerProviderModel> {
Expand All @@ -270,6 +303,14 @@ function appendCustomCodexModels(

const seen = new Set(models.map((model) => model.slug));
const fallbackCapabilities = models.find((model) => model.capabilities)?.capabilities ?? null;
const safeFallbackCapabilities = fallbackCapabilities
? {
...fallbackCapabilities,
optionDescriptors: fallbackCapabilities.optionDescriptors?.filter(
(descriptor) => descriptor.id !== "cyberAccessProgram",
),
}
: null;
const customEntries: ServerProviderModel[] = [];
for (const entry of readCustomModelEntries(customModels)) {
if (seen.has(entry.slug)) {
Expand All @@ -280,7 +321,14 @@ function appendCustomCodexModels(
slug: entry.slug,
name: entry.name,
isCustom: true,
capabilities: entry.capabilities ?? fallbackCapabilities,
capabilities: entry.capabilities
? {
...entry.capabilities,
optionDescriptors: entry.capabilities.optionDescriptors?.filter(
(descriptor) => descriptor.id !== "cyberAccessProgram",
),
}
: safeFallbackCapabilities,
});
}
return customEntries.length === 0 ? models : [...models, ...customEntries];
Expand Down Expand Up @@ -329,10 +377,19 @@ const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* (
let cursor: string | null | undefined = undefined;

do {
const response: CodexSchema.V2ModelListResponse = yield* client.request(
"model/list",
cursor ? { cursor } : {},
);
// The pinned generated schema predates account-specific access programs.
// Decode the live response with a narrow overlay so discovery keeps them.
const rawResponse: unknown = yield* client.raw.request("model/list", cursor ? { cursor } : {});
const response: typeof CodexModelListWithAccessPrograms.Type =
yield* decodeCodexModelListWithAccessPrograms(rawResponse).pipe(
Effect.mapError((error) =>
CodexErrors.CodexAppServerProtocolParseError.fromSchemaError(
"decode-response-payload",
error,
{ method: "model/list" },
),
),
);
models.push(...parseCodexModelListResponse(response));
cursor = response.nextCursor;
} while (cursor);
Expand Down
12 changes: 12 additions & 0 deletions apps/server/src/provider/Layers/CodexSessionRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,18 @@ function makeThreadOpenResponse(
}

describe("buildTurnStartParams", () => {
it.effect("sends Daybreak and explicit standard selections to the app server", () =>
Effect.gen(function* () {
for (const cyberAccessProgram of ["daybreakBlue", "daybreakRed", "standard"] as const) {
const params = yield* buildTurnStartParams({
threadId: "provider-thread-1",
runtimeMode: "full-access",
cyberAccessProgram,
});
NodeAssert.equal(params.cyberAccessProgram, cyberAccessProgram);
}
}),
);
it.effect("sends currency skill aliases in Codex's canonical dollar form", () =>
Effect.gen(function* () {
for (const symbol of ["€", "£", "¥", "₹", "₩", "₿", "𑿝"]) {
Expand Down
Loading
Loading