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
4 changes: 2 additions & 2 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1205,8 +1205,8 @@ export function NewTaskDraftScreen(props: {
isModelSelectionUnavailable(selectedEnvironmentServerConfig, modelSelection)
) {
Alert.alert(
"Antigravity model unavailable",
"Set up Antigravity on web or desktop, or choose another model.",
"Model unavailable",
"Set up the provider on web or desktop, or choose another model.",
);
return;
}
Expand Down
94 changes: 94 additions & 0 deletions apps/mobile/src/lib/modelOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,100 @@ describe("mobile model options", () => {
expect(resolveSelectableModelSelection(null, disabled)).toBe(disabled);
});

describe("Claude catalog restrictions", () => {
const selection = {
instanceId: ProviderInstanceId.make("claude_work"),
model: "claude-fable-5",
};
const allowedModel = {
slug: "claude-opus-5",
name: "Claude Opus 5",
isDefault: true,
isCustom: false,
capabilities: null,
};
const provider = {
instanceId: selection.instanceId,
driver: "claudeAgent",
enabled: true,
installed: true,
status: "ready",
auth: { status: "authenticated" },
models: [allowedModel],
};
it.each(["ready", "warning", "error"])(
"keeps a saved restricted model disabled while the provider is %s",
(status) => {
const config = {
providers: [
{
...provider,
status,
auth: { status: status === "ready" ? "authenticated" : "unknown" },
},
],
} as unknown as ServerConfig;
expect(buildModelOptions(config, null).map((option) => option.selection.model)).toEqual([
"claude-opus-5",
]);
expect(buildModelOptions(config, selection)).toMatchObject([
{ selection: { model: "claude-opus-5" } },
{ selection, isUnavailable: true },
]);
expect(isModelSelectionUnavailable(config, selection)).toBe(true);
expect(resolveSelectableModelSelection(config, selection)).toBeNull();
expect(resolveDefaultableModelSelection(config, selection)).toBeNull();
expect(
resolveNewTaskModelSelection({
draftSelection: resolveSelectableModelSelection(config, selection),
projectDefaultSelection: resolveDefaultableModelSelection(config, selection),
stickySelection: resolveDefaultableModelSelection(config, selection),
modelOptions: buildModelOptions(config, null),
}),
).toMatchObject({ instanceId: selection.instanceId, model: "claude-opus-5" });
},
);

it("allows the model again when access returns or it is explicitly configured", () => {
for (const isCustom of [false, true]) {
const restored = {
providers: [
{
...provider,
models: [allowedModel, { ...allowedModel, slug: selection.model, isCustom }],
},
],
} as unknown as ServerConfig;
expect(isModelSelectionUnavailable(restored, selection)).toBe(false);
expect(resolveSelectableModelSelection(restored, selection)).toBe(selection);
expect(
buildModelOptions(restored, selection).find(
(option) => option.selection.model === selection.model,
)?.isUnavailable,
).not.toBe(true);
}
});

it("does not infer restrictions from an unfiltered probe or an offline environment", () => {
for (const status of ["warning", "error"]) {
const unchecked = {
providers: [
{
...provider,
status,
auth: { status: "unknown" },
models: [allowedModel, { ...allowedModel, slug: selection.model }],
},
],
} as unknown as ServerConfig;
expect(isModelSelectionUnavailable(unchecked, selection)).toBe(false);
expect(resolveSelectableModelSelection(unchecked, selection)).toBe(selection);
}
expect(isModelSelectionUnavailable(null, selection)).toBe(false);
expect(resolveSelectableModelSelection(null, selection)).toBe(selection);
});
});

describe("Antigravity selections", () => {
const selection = {
instanceId: ProviderInstanceId.make("google_work"),
Expand Down
16 changes: 15 additions & 1 deletion apps/mobile/src/lib/modelOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
import {
buildExplicitProviderOptionSelectionsFromDescriptors,
getProviderOptionDescriptors,
resolveSelectableModel,
} from "@t3tools/shared/model";

export type ModelOption = {
Expand Down Expand Up @@ -61,7 +62,7 @@ function normalizeSelectionOptions(
};
}

/** Whether a known Antigravity selection needs setup or a different model. */
/** Whether a saved selection needs setup or a different model before sending. */
export function isModelSelectionUnavailable(
config: T3ServerConfig | null | undefined,
selection: ModelSelection | null | undefined,
Expand All @@ -74,6 +75,16 @@ export function isModelSelectionUnavailable(
);
const driver =
provider?.driver ?? config.settings?.providerInstances[selection.instanceId]?.driver;
// The server retains Claude's inventory through failed refreshes. A saved
// selection must not restore models it omits, even while status is unknown.
if (
provider?.driver === "claudeAgent" &&
provider.enabled &&
provider.installed &&
provider.auth.status !== "unauthenticated"
) {
return resolveSelectableModel(provider.driver, selection.model, provider.models) === null;
}
return (
driver === "antigravity" &&
(!provider ||
Expand Down Expand Up @@ -105,6 +116,9 @@ export function resolveSelectableModelSelection(
if (driver === "antigravity") {
return selection;
}
if (isModelSelectionUnavailable(config, selection)) {
return null;
}
return provider &&
provider.enabled &&
provider.installed &&
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/state/use-thread-composer-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,8 +379,8 @@ export function useThreadComposerState() {
isModelSelectionUnavailable(serverConfig, modelSelection)
) {
Alert.alert(
"Antigravity model unavailable",
"Set up Antigravity on web or desktop, or choose another model.",
"Model unavailable",
"Set up the provider on web or desktop, or choose another model.",
);
return null;
}
Expand Down
8 changes: 4 additions & 4 deletions apps/mobile/src/state/use-thread-outbox-drain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -698,7 +698,7 @@ export function useThreadOutboxDrain(): void {
if (isModelSelectionUnavailable(serverConfig, settings.modelSelection)) {
return restoreQueuedMessage(
queuedMessage,
"Antigravity model unavailable. Set it up on web or desktop, or choose another model.",
"Model unavailable. Set up the provider on web or desktop, or choose another model.",
);
}
const { reportFailure } = makeDeliveryHelpers(queuedMessage);
Expand Down Expand Up @@ -791,7 +791,7 @@ export function useThreadOutboxDrain(): void {
if (isModelSelectionUnavailable(currentConfig, settings.modelSelection)) {
return restoreQueuedMessage(
persistedMessage,
"Antigravity model unavailable. Set it up on web or desktop, or choose another model.",
"Model unavailable. Set up the provider on web or desktop, or choose another model.",
);
}
const sendSettings = resolveQueuedThreadSettings(
Expand Down Expand Up @@ -875,7 +875,7 @@ export function useThreadOutboxDrain(): void {
if (isModelSelectionUnavailable(serverConfig, settings.modelSelection)) {
return restoreQueuedMessage(
queuedMessage,
"Antigravity model unavailable. Set it up on web or desktop, or choose another model.",
"Model unavailable. Set up the provider on web or desktop, or choose another model.",
);
}
let prepared: PreparedTurnAttachments;
Expand Down Expand Up @@ -919,7 +919,7 @@ export function useThreadOutboxDrain(): void {
if (isModelSelectionUnavailable(currentConfig, settings.modelSelection)) {
return restoreQueuedMessage(
persistedMessage,
"Antigravity model unavailable. Set it up on web or desktop, or choose another model.",
"Model unavailable. Set up the provider on web or desktop, or choose another model.",
);
}
const sendSettings = resolveQueuedThreadSettings(
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/provider/Drivers/ClaudeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
lookup: () =>
probeClaudeCapabilities(effectiveConfig, processEnv, cwd).pipe(
Effect.provideService(Path.Path, path),
Effect.provideService(FileSystem.FileSystem, fileSystem),
),
});
const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd);
Expand Down
166 changes: 166 additions & 0 deletions apps/server/src/provider/Drivers/ClaudeEntitlements.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";

import { readClaudeRestrictedModels } from "./ClaudeEntitlements.ts";

const writeClaudeConfig = Effect.fn(function* (configDir: string, contents: string) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
yield* fs.makeDirectory(configDir, { recursive: true });
yield* fs.writeFileString(path.join(configDir, ".claude.json"), contents);
});

const makeConfigDir = Effect.fn(function* (name: string) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-entitlements-" });
return path.join(tempDir, name);
});

it.layer(NodeServices.layer)("readClaudeRestrictedModels", (it) => {
it.effect("returns only the models the organization has disallowed", () =>
Effect.gen(function* () {
const configDir = yield* makeConfigDir("claude-home");
// The real file carries dozens of unrelated keys around the cache, and
// names older models by dated API id where the catalog uses the bare
// slug.
yield* writeClaudeConfig(
configDir,
`{
"numStartups": 12,
"oauthAccount": { "emailAddress": "dev@example.com" },
"modelAccessCache": [
{ "apiName": "claude-fable-5", "entitled": false },
{ "apiName": "claude-fable-5-1", "entitled": false },
{ "apiName": "claude-haiku-4-5-20251001", "entitled": false },
{ "apiName": "claude-opus-4-5-20251101", "entitled": true },
{ "apiName": "claude-opus-5", "entitled": true },
{ "apiName": "claude-sonnet-5", "entitled": true }
]
}`,
);

const restricted = yield* readClaudeRestrictedModels({ CLAUDE_CONFIG_DIR: configDir });

assert.deepEqual([...restricted], ["claude-fable-5", "claude-fable-5-1", "claude-haiku-4-5"]);
}),
);

it.effect("reads ~/.claude.json of the home the CLI is spawned with", () =>
Effect.gen(function* () {
const home = yield* makeConfigDir("home");
yield* writeClaudeConfig(
home,
`{ "modelAccessCache": [{ "apiName": "claude-fable-5", "entitled": false }] }`,
);

// An instance environment may override HOME; the reader has to follow
// it to the same file the child reads rather than the server's own.
const restricted = yield* readClaudeRestrictedModels({ HOME: home });

assert.deepEqual([...restricted], ["claude-fable-5"]);
}),
);

it.effect("prefers the literal config directory over HOME", () =>
Effect.gen(function* () {
const home = yield* makeConfigDir("home");
const configDir = yield* makeConfigDir("claude-home ");
yield* writeClaudeConfig(
home,
`{ "modelAccessCache": [{ "apiName": "claude-opus-5", "entitled": false }] }`,
);
yield* writeClaudeConfig(
configDir,
`{ "modelAccessCache": [{ "apiName": "claude-fable-5", "entitled": false }] }`,
);

assert.deepEqual(
[...(yield* readClaudeRestrictedModels({ HOME: home, CLAUDE_CONFIG_DIR: configDir }))],
["claude-fable-5"],
);
assert.deepEqual(
[...(yield* readClaudeRestrictedModels({ HOME: home, CLAUDE_CONFIG_DIR: "" }))],
["claude-opus-5"],
);
}),
);

it.effect("restricts nothing for a relative config dir or home", () =>
Effect.gen(function* () {
// The CLI resolves a relative CLAUDE_CONFIG_DIR or HOME against each
// session's own working directory, so no single file speaks for the
// environment. The file must not even be consulted: this filesystem
// would answer every read with a restriction.
const reads: Array<string> = [];
const restrictiveFileSystem = FileSystem.layerNoop({
readFileString: (filePath) =>
Effect.sync(() => {
reads.push(filePath);
return `{ "modelAccessCache": [{ "apiName": "claude-fable-5", "entitled": false }] }`;
}),
});

for (const environment of [
{ CLAUDE_CONFIG_DIR: "./claude" },
{ CLAUDE_CONFIG_DIR: " /claude" },
{ HOME: "home" },
]) {
const restricted = yield* readClaudeRestrictedModels(environment).pipe(
Effect.provide(restrictiveFileSystem),
);
assert.deepEqual([...restricted], []);
}
assert.deepEqual(reads, []);
}),
);

it.effect("restricts nothing when the config is missing or malformed", () =>
Effect.gen(function* () {
const absent = yield* makeConfigDir("absent-home");
assert.deepEqual([...(yield* readClaudeRestrictedModels({ CLAUDE_CONFIG_DIR: absent }))], []);

const brokenJson = yield* makeConfigDir("broken-json");
yield* writeClaudeConfig(brokenJson, "{ not json");
assert.deepEqual(
[...(yield* readClaudeRestrictedModels({ CLAUDE_CONFIG_DIR: brokenJson }))],
[],
);

const brokenCache = yield* makeConfigDir("broken-cache");
yield* writeClaudeConfig(brokenCache, `{ "modelAccessCache": { "claude-fable-5": false } }`);
assert.deepEqual(
[...(yield* readClaudeRestrictedModels({ CLAUDE_CONFIG_DIR: brokenCache }))],
[],
);
}),
);

it.effect("ignores entries that carry no usable model id or verdict", () =>
Effect.gen(function* () {
const configDir = yield* makeConfigDir("partial-home");
yield* writeClaudeConfig(
configDir,
`{
"modelAccessCache": [
null,
"claude-fable-5",
{ "entitled": false },
{ "apiName": " ", "entitled": false },
{ "apiName": "claude-opus-5" },
{ "apiName": "claude-sonnet-4-6", "entitled": false }
]
}`,
);

const restricted = yield* readClaudeRestrictedModels({ CLAUDE_CONFIG_DIR: configDir });

// Only an explicit `false` restricts: an absent verdict is unknown, not
// disallowed, and one odd entry does not cost the others.
assert.deepEqual([...restricted], ["claude-sonnet-4-6"]);
}),
);
});
Loading
Loading