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
16 changes: 11 additions & 5 deletions apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,20 @@ function ModelRow(props: {
readonly isFirst: boolean;
readonly isLast: boolean;
}) {
const restricted = props.option.unavailableReason !== null;
// The reason replaces the subtitle: it is the one thing that explains why
// the row can't be picked, and rows only have space for a single detail line.
const detail = props.option.unavailableReason ?? props.option.subtitle;
return (
<Pressable
accessibilityLabel={[props.option.label, props.option.subtitle].filter(Boolean).join(", ")}
accessibilityLabel={[props.option.label, detail].filter(Boolean).join(", ")}
accessibilityRole="radio"
accessibilityState={{ checked: props.selected }}
accessibilityState={{ checked: props.selected, disabled: restricted }}
disabled={restricted}
onPress={props.onPress}
className={cn(
"mx-4 min-h-11 flex-row items-center gap-2 bg-card px-4 py-2 active:bg-subtle",
"mx-4 min-h-11 flex-row items-center gap-2 bg-card px-4 py-2",
restricted ? "opacity-40" : "active:bg-subtle",
props.isFirst && "rounded-t-2xl",
props.isLast ? "rounded-b-2xl" : "border-b border-border-subtle",
)}
Expand All @@ -129,9 +135,9 @@ function ModelRow(props: {
</View>
) : null}
</View>
{props.option.subtitle ? (
{detail ? (
<Text className="text-xs text-foreground-muted" numberOfLines={1}>
{props.option.subtitle}
{detail}
</Text>
) : null}
</View>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ function modelOption(
providerDriver: "codex",
isDefault: false,
isLegacy: false,
unavailableReason: null,
capabilities: null,
selection: {
instanceId: ProviderInstanceId.make("codex"),
Expand Down
9 changes: 9 additions & 0 deletions apps/mobile/src/lib/modelOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ export type ModelOption = {
readonly providerDriver: string;
readonly isDefault: boolean;
readonly isLegacy: boolean;
/**
* Why this environment can't run the model (today: not entitled by the
* Claude account's organization), or `null` when it can. Picking a
* restricted model silently runs the org default instead, so rows carrying
* a reason are shown disabled.
*/
readonly unavailableReason: string | null;
readonly capabilities: ModelCapabilities | null;
readonly selection: ModelSelection;
};
Expand Down Expand Up @@ -143,6 +150,7 @@ export function buildModelOptions(
providerDriver: provider.driver,
isDefault: model.isDefault === true,
isLegacy: model.isLegacy === true,
unavailableReason: model.unavailableReason ?? null,
capabilities: model.capabilities,
selection: normalizeSelectionOptions(
{
Expand Down Expand Up @@ -174,6 +182,7 @@ export function buildModelOptions(
providerDriver: fallbackModelSelection.instanceId,
isDefault: false,
isLegacy: false,
unavailableReason: null,
capabilities: null,
selection: fallbackModelSelection,
});
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 @@ -162,6 +162,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
111 changes: 111 additions & 0 deletions apps/server/src/provider/Drivers/ClaudeEntitlements.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
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");
yield* writeClaudeConfig(
configDir,
JSON.stringify({
modelAccessCache: [
{ apiName: "claude-fable-5", entitled: false },
{ apiName: "claude-opus-5", entitled: true },
{ apiName: "claude-sonnet-5", entitled: true },
{ apiName: "claude-opus-4-8", entitled: true },
],
}),
);

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

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

it.effect("reads the config beside a CLAUDE_CONFIG_DIR from the environment", () =>
Effect.gen(function* () {
const configDir = yield* makeConfigDir("ambient-home");
yield* writeClaudeConfig(
configDir,
JSON.stringify({ modelAccessCache: [{ apiName: "claude-fable-5", entitled: false }] }),
);

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

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

it.effect("restricts nothing when the config is missing", () =>
Effect.gen(function* () {
const configDir = yield* makeConfigDir("absent-home");

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

assert.deepEqual([...restricted], []);
}),
);

it.effect("restricts nothing when the config or its cache is malformed", () =>
Effect.gen(function* () {
const brokenJson = yield* makeConfigDir("broken-json");
yield* writeClaudeConfig(brokenJson, "{ not json");
assert.deepEqual([...(yield* readClaudeRestrictedModels({ homePath: brokenJson }))], []);

const brokenCache = yield* makeConfigDir("broken-cache");
yield* writeClaudeConfig(
brokenCache,
JSON.stringify({ modelAccessCache: { "claude-fable-5": false } }),
);
assert.deepEqual([...(yield* readClaudeRestrictedModels({ homePath: 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,
JSON.stringify({
modelAccessCache: [
null,
"claude-fable-5",
{ entitled: false },
{ apiName: " ", entitled: false },
// Only an explicit `false` restricts: an absent verdict is unknown,
// not disallowed.
{ apiName: "claude-opus-5" },
{ apiName: "claude-sonnet-4-6", entitled: false },
],
}),
);

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

assert.deepEqual([...restricted], ["claude-sonnet-4-6"]);
}),
);
});
103 changes: 103 additions & 0 deletions apps/server/src/provider/Drivers/ClaudeEntitlements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* ClaudeEntitlements — reads which models the account's organization allows.
*
* Enterprise and team organizations can disallow individual models. Claude
* Code records the resolved per-model entitlements in its config file under
* `modelAccessCache`, the same list its own `/model` picker greys rows out
* from, and falls back to the org default when a disallowed model is
* requested — emitting only an `informational` notice mid-turn, after the
* user already picked it.
*
* The Agent SDK init handshake is not a usable substitute here: its model
* catalog is the CLI's curated picker list, so a model can be missing from it
* and still run (`claude-opus-4-8` is absent yet answers normally). Absence
* therefore cannot be read as "disallowed", while `entitled: false` can.
*
* Reading is best effort in both directions: an unreadable, malformed, or
* absent cache yields no restrictions, so the picker degrades to today's
* behavior rather than hiding models the org actually allows.
*
* @module provider/Drivers/ClaudeEntitlements
*/
import * as NodeOS from "node:os";

import type { ClaudeSettings } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";

import { expandHomePath } from "../../pathExpansion.ts";

/**
* Resolve the `.claude.json` the spawned CLI would read, matching the
* precedence in {@link makeClaudeEnvironment}: the instance's `homePath`
* (exported as `CLAUDE_CONFIG_DIR`), then a `CLAUDE_CONFIG_DIR` already in the
* process environment, then `~/.claude.json`.
*
* Note this is the config *file* beside the `.claude` directory, not inside
* it, so it does not share `ClaudeSkills`' config-dir resolution.
*/
const resolveClaudeConfigFilePath = Effect.fn("resolveClaudeConfigFilePath")(function* (
config: Pick<ClaudeSettings, "homePath">,
environment: NodeJS.ProcessEnv,
cwd?: string,
): Effect.fn.Return<string, never, Path.Path> {
const path = yield* Path.Path;
const homePath = config.homePath.trim();
if (homePath.length > 0) {
return path.join(path.resolve(expandHomePath(homePath)), ".claude.json");
}
// No tilde expansion: the spawned CLI receives this env var verbatim, so a
// literal `~` must stay literal to land on the same file the runtime reads.
const environmentConfigDir = environment.CLAUDE_CONFIG_DIR?.trim() ?? "";
if (environmentConfigDir.length > 0) {
const resolved = cwd
? path.resolve(cwd, environmentConfigDir)
: path.resolve(environmentConfigDir);
return path.join(resolved, ".claude.json");
}
return path.join(NodeOS.homedir(), ".claude.json");
});

/**
* Model ids the organization has explicitly disallowed, as API model ids
* (`claude-fable-5`). Entries the cache marks entitled, and models it does not
* mention at all, are omitted — only an explicit `entitled: false` restricts.
*/
export const readClaudeRestrictedModels = Effect.fn("readClaudeRestrictedModels")(function* (
config: Pick<ClaudeSettings, "homePath">,
environment?: NodeJS.ProcessEnv,
cwd?: string,
): Effect.fn.Return<ReadonlySet<string>, never, FileSystem.FileSystem | Path.Path> {
const fileSystem = yield* FileSystem.FileSystem;
const configFilePath = yield* resolveClaudeConfigFilePath(
config,
environment ?? process.env,
cwd,
);

const contents = yield* fileSystem
.readFileString(configFilePath)
.pipe(Effect.orElseSucceed(() => ""));
if (contents.length === 0) return new Set<string>();

const parsed = yield* Effect.try(() => JSON.parse(contents) as unknown).pipe(
Effect.orElseSucceed(() => undefined),
);
const modelAccessCache = (parsed as { readonly modelAccessCache?: unknown } | undefined)
?.modelAccessCache;
if (!Array.isArray(modelAccessCache)) return new Set<string>();

const restricted = new Set<string>();
for (const entry of modelAccessCache) {
if (typeof entry !== "object" || entry === null) continue;
const { apiName, entitled } = entry as {
readonly apiName?: unknown;
readonly entitled?: unknown;
};
if (entitled !== false || typeof apiName !== "string") continue;
const normalized = apiName.trim();
if (normalized.length > 0) restricted.add(normalized);
}
return restricted;
});
28 changes: 26 additions & 2 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2610,6 +2610,14 @@ describe("ClaudeAdapterLive", () => {
{ type: "system", subtype: "plugin_install", session_id: "session", uuid: "pi" },
{ type: "system", subtype: "memory_recall", session_id: "session", uuid: "mr" },
{ type: "system", subtype: "elicitation_complete", session_id: "session", uuid: "ec" },
{
type: "system",
subtype: "informational",
content: "Transcript-only note.",
level: "info",
session_id: "session",
uuid: "info-quiet",
},
{ type: "prompt_suggestion", suggestion: "try this", session_id: "session", uuid: "ps" },
{
type: "system",
Expand All @@ -2633,6 +2641,17 @@ describe("ClaudeAdapterLive", () => {
session_id: "session",
uuid: "notif-high",
} as unknown as SDKMessage);
// Warning-level informational notices surface too: this is how the CLI
// reports that an org-restricted model was swapped for another one.
harness.query.emit({
type: "system",
subtype: "informational",
content:
'Model "claude-fable-5" is restricted by your organization\'s settings. Using claude-opus-5[1m] instead.',
level: "warning",
session_id: "session",
uuid: "info-warning",
} as unknown as SDKMessage);
// session_state_changed maps to the matching session states.
for (const [state, uuid] of [
["running", "ssc-run"],
Expand Down Expand Up @@ -2663,10 +2682,15 @@ describe("ClaudeAdapterLive", () => {
yield* Effect.yieldNow;

const warnings = runtimeEvents.filter((event) => event.type === "runtime.warning");
// Exactly one warning: the high-priority notification. Nothing else.
// Only the high-priority notification and the warning-level
// informational. Nothing else, and neither quiet informational nor any
// undeclared subtype leaks through as an unknown-subtype row.
assert.deepEqual(
warnings.map((event) => event.payload.message),
["context window nearly full"],
[
"context window nearly full",
'Model "claude-fable-5" is restricted by your organization\'s settings. Using claude-opus-5[1m] instead.',
],
);
const sessionStates = runtimeEvents
.filter((event) => event.type === "session.state.changed")
Expand Down
19 changes: 19 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3130,6 +3130,25 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
return;
}

// `informational` is another real-but-undeclared subtype. Its warning level
// carries notices the user has to see to understand the turn — notably the
// org-restricted model substitution ("Model "…" is restricted by your
// organization's settings. Using … instead."), which decides which model
// actually answered. Without a case it reached the unknown-subtype branch
// and surfaced as an error row that named no model. Quieter levels are
// transcript and footer chrome, so they stay consumed.
if ((message.subtype as string) === "informational") {
const informational = message as unknown as {
readonly content?: unknown;
readonly level?: unknown;
};
const content = typeof informational.content === "string" ? informational.content.trim() : "";
if (informational.level === "warning" && content.length > 0) {
yield* emitRuntimeWarning(context, content, message);
}
return;
}

switch (message.subtype) {
case "init":
yield* offerRuntimeEvent({
Expand Down
Loading
Loading