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
6 changes: 5 additions & 1 deletion apps/server/src/provider/Drivers/CodexDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,6 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
environment: processEnv,
...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
});
const textGeneration = yield* makeCodexTextGeneration(effectiveConfig, processEnv);

// Build a managed snapshot whose settings never change — mutations come
// in as instance rebuilds from the registry rather than in-place
Expand Down Expand Up @@ -272,6 +271,11 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
}),
),
);
const textGeneration = yield* makeCodexTextGeneration(
effectiveConfig,
processEnv,
snapshot.getSnapshot.pipe(Effect.map((value) => value.models)),
);
const snapshotForCwd = (cwd: string) =>
!effectiveConfig.enabled
? snapshot.getSnapshot
Expand Down
23 changes: 23 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,29 @@ describe("ClaudeAdapterLive", () => {
);
});

it.effect("lets a launch-arg permission flag win over the thread runtime mode", () => {
const harness = makeHarness({
claudeConfig: { launchArgs: "--dangerously-skip-permissions --verbose" },
});
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;
yield* adapter.startSession({
threadId: THREAD_ID,
provider: ProviderDriverKind.make("claudeAgent"),
runtimeMode: "auto-accept-edits",
});

const createInput = harness.getLastCreateQueryInput();
assert.equal(createInput?.options.permissionMode, "bypassPermissions");
assert.equal(createInput?.options.allowDangerouslySkipPermissions, true);
// The honored flag is dropped from extraArgs so the CLI sees it once.
assert.deepEqual(createInput?.options.extraArgs, { verbose: null });
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});

it.effect("loads Claude filesystem settings sources for SDK sessions", () => {
const harness = makeHarness();
return Effect.gen(function* () {
Expand Down
15 changes: 13 additions & 2 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4741,7 +4741,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
) => runPromise(handleResumeDialog(request, callbackOptions));

const claudeBinaryPath = claudeSdkExecutablePath;
const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags;
const {
"permission-mode": launchArgPermissionMode,
"dangerously-skip-permissions": launchArgSkipPermissions,
...extraArgs
} = parseCliArgs(claudeSettings.launchArgs).flags;
const selectedModel =
input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined;
const modelSelection = selectedModel
Expand Down Expand Up @@ -4782,7 +4786,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
auto: "auto",
"full-access": "bypassPermissions",
};
const permissionMode = runtimeModeToPermission[input.runtimeMode];
// A permission launch arg is folded into the mode T3 sends rather than
// passed through: the CLI resolves both inputs together, so argv order
// never let the user's flag win.
const permissionMode =
(launchArgPermissionMode as PermissionMode | null | undefined) ??
(launchArgSkipPermissions === null || launchArgSkipPermissions === "true"
? "bypassPermissions"
: runtimeModeToPermission[input.runtimeMode]);
const settings = {
...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}),
...(fastMode ? { fastMode: true } : {}),
Expand Down
17 changes: 17 additions & 0 deletions apps/server/src/provider/Layers/CodexProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,23 @@ it("prefers sol over terra when both are available", () => {
assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.6-sol");
});

it("ranks qualified Codex models while preserving their wire ids", () => {
const models = applyPreferredCodexDefaultModel([
{
slug: "openai.gpt-5.6-luna",
name: "Luna",
isCustom: false,
isDefault: true,
capabilities: null,
},
{ slug: "openai.gpt-5.6-sol", name: "Sol", isCustom: false, capabilities: null },
]);
assert.deepStrictEqual(
models.filter((model) => model.isDefault).map((model) => model.slug),
["openai.gpt-5.6-sol"],
);
});

it("keeps Codex's own default when no preferred model is available", () => {
const models = applyPreferredCodexDefaultModel([
{ slug: "gpt-5.5", name: "GPT-5.5", isCustom: false, capabilities: null },
Expand Down
12 changes: 8 additions & 4 deletions apps/server/src/provider/Layers/CodexProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ import type {
} from "@t3tools/contracts";
import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts";

import { createModelCapabilities, readCustomModelEntries } from "@t3tools/shared/model";
import {
codexModelFamily,
createModelCapabilities,
readCustomModelEntries,
} from "@t3tools/shared/model";
import { resolveSpawnCommand } from "@t3tools/shared/shell";
import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts";
import {
Expand Down Expand Up @@ -243,9 +247,9 @@ function parseCodexModelListResponse(
export function applyPreferredCodexDefaultModel(
models: ReadonlyArray<ServerProviderModel>,
): ReadonlyArray<ServerProviderModel> {
const preferredSlug = PREFERRED_DEFAULT_CODEX_MODELS.find((slug) =>
models.some((model) => model.slug === slug && !model.isCustom),
);
const preferredSlug = PREFERRED_DEFAULT_CODEX_MODELS.flatMap((slug) =>
models.filter((model) => !model.isCustom && codexModelFamily(model.slug) === slug),
)[0]?.slug;
if (!preferredSlug) {
return models;
}
Expand Down
60 changes: 60 additions & 0 deletions apps/server/src/provider/ModelManifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import * as ServerConfig from "../config.ts";
import * as ServerSettings from "../serverSettings.ts";
import {
BUNDLED_MODEL_MANIFEST,
applyManifestDefault,
classifyModels,
make,
resolveProviderCatalog,
Expand All @@ -38,6 +39,20 @@ const model = (overrides: Partial<ServerProviderModel>): ServerProviderModel =>
});

describe("classifyModels", () => {
it("classifies qualified Codex families without changing their wire ids", () => {
const manifest: ModelManifestData = { version: 1, currentModels: { codex: ["gpt-test"] } };
const models = [
model({ slug: "openai.gpt-test", isLegacy: true }),
model({ slug: "openai.gpt-old" }),
];
assert.deepStrictEqual(
classifyModels(models, manifest, CODEX).map((entry) => [entry.slug, entry.isLegacy ?? false]),
[
["openai.gpt-test", false],
["openai.gpt-old", true],
],
);
});
it("flags non-current models, clears stale flags, and skips custom models", () => {
const manifest: ModelManifestData = {
version: 1,
Expand All @@ -63,6 +78,51 @@ describe("classifyModels", () => {
});
});

describe("applyManifestDefault", () => {
it("resolves the manifest default to the qualified live model", () => {
const manifest: ModelManifestData = {
version: 1,
currentModels: {},
providers: { codex: { models: [], profiles: {}, defaults: { chat: "gpt-test" } } },
};
const models = [
model({ slug: "openai.gpt-old", isDefault: true }),
model({ slug: "openai.gpt-test" }),
];
assert.strictEqual(
applyManifestDefault(models, manifest, CODEX).find((entry) => entry.isDefault)?.slug,
"openai.gpt-test",
);
});
it("moves the default flag and its aliases to the manifest's chat default", () => {
const driver = ProviderDriverKind.make("antigravity");
const manifest: ModelManifestData = {
version: 1,
currentModels: {},
providers: {
antigravity: {
defaults: { chat: "gemini-new" },
profiles: {},
models: [{ slug: "gemini-new", name: "New", status: "current" }],
},
},
};
const models = [
model({ slug: "gemini-old", isDefault: true, aliases: ["antigravity-default"] }),
model({ slug: "gemini-new" }),
];
assert.deepStrictEqual(applyManifestDefault(models, manifest, driver), [
model({ slug: "gemini-old" }),
model({ slug: "gemini-new", isDefault: true, aliases: ["antigravity-default"] }),
]);
// The account does not offer the manifest default: keep the runtime's choice.
assert.deepStrictEqual(
applyManifestDefault(models.slice(0, 1), manifest, driver),
models.slice(0, 1),
);
});
});

describe("resolveProviderCatalog", () => {
it("resolves generic model presentation through a reusable profile", () => {
const manifest: ModelManifestData = {
Expand Down
75 changes: 62 additions & 13 deletions apps/server/src/provider/ModelManifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
type ProviderDriverKind,
type ServerProviderModel,
} from "@t3tools/contracts";
import { codexModelFamily } from "@t3tools/shared/model";
import * as Clock from "effect/Clock";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -233,26 +234,20 @@ const encodeManifestCache = Schema.encodeEffect(
);

/** True when the manifest classifies `slug` as legacy for `driverKind`. */
/** The manifest's chat default for `driverKind`, when it names one. */
export function manifestDefaultModel(
manifest: ModelManifestData,
driverKind: ProviderDriverKind,
): string | undefined {
return manifest.providers?.[driverKind]?.defaults?.chat;
}

function isLegacyModel(
manifest: ModelManifestData,
driverKind: ProviderDriverKind,
slug: string,
): boolean {
const catalogModel = manifest.providers?.[driverKind]?.models.find(
(model) => model.slug === slug,
);
const family = driverKind === "codex" ? codexModelFamily(slug) : slug;
const catalog = manifest.providers?.[driverKind]?.models;
const catalogModel =
catalog?.find((model) => model.slug === slug) ??
catalog?.find((model) => model.slug === family);
if (catalogModel) return catalogModel.status === "legacy";
const currentModels = manifest.currentModels[driverKind];
if (!currentModels) return false;
return !currentModels.includes(slug);
return !currentModels.includes(slug) && !currentModels.includes(family);
}

/**
Expand All @@ -264,7 +259,61 @@ export function applyModelManifest(
manifest: ModelManifestData,
driverKind: ProviderDriverKind,
): ServerProviderDraft {
return { ...draft, models: classifyModels(draft.models, manifest, driverKind) };
return {
...draft,
models: applyManifestDefault(
classifyModels(draft.models, manifest, driverKind),
manifest,
driverKind,
),
};
}

/** The manifest's chat default for `driverKind`, when it names one. */
export function manifestDefaultModel(
manifest: ModelManifestData,
driverKind: ProviderDriverKind,
): string | undefined {
return manifest.providers?.[driverKind]?.defaults?.chat;
}

/**
* Moves `isDefault` to the manifest's chat default when the catalog carries
* it. Providers that learn their default from the runtime (Antigravity takes
* Google's current model) can be overridden here without a release. Aliases
* that pointed at the old default move with the flag so the shared
* "provider default" alias keeps resolving.
*/
export function applyManifestDefault(
models: ReadonlyArray<ServerProviderModel>,
manifest: ModelManifestData,
driverKind: ProviderDriverKind,
): ReadonlyArray<ServerProviderModel> {
const requestedSlug = manifestDefaultModel(manifest, driverKind);
if (requestedSlug === undefined) return models;
const slug =
models.find((model) => model.slug === requestedSlug)?.slug ??
(driverKind === "codex"
? models.find(
(model) =>
!model.isCustom && codexModelFamily(model.slug) === codexModelFamily(requestedSlug),
)?.slug
: undefined);
if (slug === undefined) return models;
const previous = models.find((model) => model.isDefault && model.slug !== slug);
if (!previous) return models;
const movedAliases = previous.aliases ?? [];
return models.map((model) => {
if (model.slug === previous.slug) {
const { isDefault: _isDefault, aliases: _aliases, ...rest } = model;
return rest;
}
if (model.slug === slug) {
const aliases = [...new Set([...(model.aliases ?? []), ...movedAliases])];
return { ...model, isDefault: true, ...(aliases.length > 0 ? { aliases } : {}) };
}
return model;
});
}

/** Model-level half of `applyModelManifest`, exported for focused tests. */
Expand Down
35 changes: 34 additions & 1 deletion apps/server/src/textGeneration/CodexTextGeneration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ function withFakeCodexEnv<A, E, R>(
input: FakeCodexInput & {
launchArgs?: string;
environment?: NodeJS.ProcessEnv;
models?: ReadonlyArray<string>;
},
effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect<A, E, R>,
) {
Expand All @@ -142,12 +143,44 @@ function withFakeCodexEnv<A, E, R>(
const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" });
const codexPath = yield* makeFakeCodexBinary(tempDir, input);
const config = decodeCodexSettings({ binaryPath: codexPath, launchArgs: input.launchArgs });
const textGeneration = yield* makeCodexTextGeneration(config, input.environment);
const textGeneration = yield* makeCodexTextGeneration(
config,
input.environment,
Effect.succeed(
(input.models ?? []).map((slug) => ({
slug,
name: slug,
isCustom: false,
capabilities: null,
})),
),
);
return yield* effectFn(textGeneration);
}).pipe(Effect.scoped);
}

it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => {
for (const selectedModel of ["gpt-5.6-luna", "openai.gpt-5.6-luna"]) {
it.effect(`dispatches the qualified live model for ${selectedModel}`, () =>
withFakeCodexEnv(
{
output: JSON.stringify({ title: "Bedrock title" }),
models: ["openai.gpt-5.6-luna"],
requireArg: "--model openai.gpt-5.6-luna",
forbidArg: "--model gpt-5.6-luna",
},
(textGeneration) =>
Effect.gen(function* () {
const result = yield* textGeneration.generateThreadTitle({
cwd: process.cwd(),
message: "Describe this change",
modelSelection: createModelSelection(ProviderInstanceId.make("codex"), selectedModel),
});
expect(result.title).toBe("Bedrock title");
}),
),
);
}
it.effect("generates and sanitizes commit messages without branch by default", () =>
withFakeCodexEnv(
{
Expand Down
Loading
Loading