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
39 changes: 23 additions & 16 deletions apps/server/src/provider/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,12 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
const slashCommands = [COMPACT_SLASH_COMMAND, ...(capabilities?.slashCommands ?? [])];
const dedupedSlashCommands = dedupeSlashCommands(slashCommands);

// First-party account fields cannot verify custom endpoint credentials.
const baseUrl =
(yield* readClaudeSettingsEnv(claudeSettings, cwd, resolvedEnvironment)).ANTHROPIC_BASE_URL ??
resolvedEnvironment.ANTHROPIC_BASE_URL;
const customApiEndpoint = customClaudeApiEndpointHost(baseUrl) !== undefined;

Comment thread
maria-rcks marked this conversation as resolved.
Resolved
if (!capabilities) {
return buildServerProvider({
presentation: CLAUDE_PRESENTATION,
Expand All @@ -821,15 +827,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
? `Timed out after ${resolveProviderProbeTimeoutMs("claude", CAPABILITIES_PROBE_TIMEOUT_MS, resolvedEnvironment) / 1_000}s while checking Claude account status.`
: "Claude Agent CLI failed while checking account status."
: "Could not verify Claude authentication status from initialization result.",
...(customApiEndpoint
? { usageLimits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) }
: {}),
},
});
}

// First-party account fields cannot verify custom endpoint credentials.
const baseUrl =
(yield* readClaudeSettingsEnv(claudeSettings, cwd, resolvedEnvironment)).ANTHROPIC_BASE_URL ??
resolvedEnvironment.ANTHROPIC_BASE_URL;
const customApiEndpoint = customClaudeApiEndpointHost(baseUrl) !== undefined;
const endpoint = customApiEndpoint ? URL.parse(baseUrl?.trim() ?? "")?.origin : undefined;
if (!customApiEndpoint && claudeAuthStatus(capabilities) === "unauthenticated") {
const path = yield* Path.Path;
Expand Down Expand Up @@ -868,17 +872,20 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
apiKeySource: capabilities.apiKeySource,
});
const usageCheckedAt = capabilities.usageCheckedAt ?? checkedAt;
const usageLimits = !capabilities.usage
? makeUnavailableUsageLimits({ checkedAt, reason: "probeFailed" })
: scopedLimitNames
? yield* recordClaudeUsageResponse(scopedLimitNames, {
response: capabilities.usage,
checkedAt: usageCheckedAt,
})
: claudeUsageResponseToLimits({
response: capabilities.usage,
checkedAt: usageCheckedAt,
}).limits;
// A custom endpoint answers usage for whichever account it routes to, not this login.
const usageLimits = customApiEndpoint
? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" })
: !capabilities.usage
? makeUnavailableUsageLimits({ checkedAt, reason: "probeFailed" })
: scopedLimitNames
? yield* recordClaudeUsageResponse(scopedLimitNames, {
response: capabilities.usage,
checkedAt: usageCheckedAt,
})
: claudeUsageResponseToLimits({
response: capabilities.usage,
checkedAt: usageCheckedAt,
}).limits;
const resetCredits =
resolveResetCredits &&
capabilities.subscriptionType &&
Expand Down
10 changes: 2 additions & 8 deletions apps/server/src/provider/Drivers/ClaudeSkills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -658,7 +658,7 @@ const decodeSettingsEnv = Schema.decodeUnknownEffect(
fromLenientJson(Schema.Struct({ env: Schema.optional(SettingsEnv) })),
);

/** The `env` values T3 Code reads from Claude's user and managed settings; later files win. */
/** The `env` values T3 Code reads from Claude's settings; later files win. */
export const readClaudeSettingsEnv = Effect.fn("readClaudeSettingsEnv")(function* (
config: Pick<ClaudeSettings, "homePath">,
cwd: string | undefined,
Expand All @@ -669,13 +669,7 @@ export const readClaudeSettingsEnv = Effect.fn("readClaudeSettingsEnv")(function
const platform = yield* HostProcess.Platform;
const configDirPath = yield* resolveClaudeConfigDirPath(config, environment, cwd);
const settingsEnv: typeof SettingsEnv.Type = {};
for (const settingsPath of claudeSettingsPaths(
path,
configDirPath,
undefined,
platform,
environment,
)) {
for (const settingsPath of claudeSettingsPaths(path, configDirPath, cwd, platform, environment)) {
const contents = yield* fileSystem
.readFileString(settingsPath)
.pipe(Effect.orElseSucceed(() => undefined));
Expand Down
42 changes: 38 additions & 4 deletions apps/server/src/provider/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import * as NodeServices from "@effect/platform-node/NodeServices";
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
import { describe, it, assert, expect } from "@effect/vitest";
import * as Cause from "effect/Cause";
import * as DateTime from "effect/DateTime";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -53,7 +54,7 @@ import {
type CodexAppServerProviderSnapshot,
} from "./CodexProvider.ts";
import { BUNDLED_CLAUDE_MODEL_CATALOG } from "./ClaudeModelCatalog.ts";
import { checkClaudeProviderStatus } from "./ClaudeProvider.ts";
import { checkClaudeProviderStatus, type ClaudeCapabilitiesProbe } from "./ClaudeProvider.ts";
import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
import * as AntigravityInstallation from "./AntigravityInstallation.ts";
import * as ModelManifest from "./ModelManifest.ts";
Expand Down Expand Up @@ -187,6 +188,7 @@ type TestClaudeCapabilities = {
readonly apiKeySource: string | undefined;
readonly apiProvider: string | undefined;
readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>;
readonly usage?: NonNullable<ClaudeCapabilitiesProbe["usage"]>;
readonly models?: ReadonlyArray<ModelInfo>;
};

Expand Down Expand Up @@ -4128,6 +4130,32 @@ it.layer(
),
);

it.effect.each([
{ baseUrl: "https://proxy.example/api", reason: "unsupported" },
{ baseUrl: "https://api.anthropic.com", reason: undefined },
])(
"preserves quota classification when the Claude probe fails: $baseUrl",
({ baseUrl, reason }) =>
Effect.gen(function* () {
const status = yield* checkClaudeProviderStatus(
defaultClaudeSettings,
() => Effect.fail(new Cause.TimeoutError("probe timed out")),
{ ANTHROPIC_BASE_URL: baseUrl },
);
assert.strictEqual(status.status, "warning");
assert.strictEqual(status.auth.status, "unknown");
assert.strictEqual(status.usageLimits?.unavailable?.reason, reason);
if (reason) assert.deepStrictEqual(status.usageLimits?.windows, []);
}).pipe(
Effect.provide(
layerMockSpawner((args) => {
if (args.join(" ") === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
throw new Error("Unexpected command");
}),
),
),
);

it.effect.each([
{ source: "inherited HTTPS endpoint", baseUrl: "https://proxy.example/api", overrides: [] },
{
Expand All @@ -4148,13 +4176,19 @@ it.layer(
tokenSource: "none",
apiKeySource: "none",
apiProvider: "firstParty",
usage: {
rate_limits_available: true,
rate_limits: { five_hour: { utilization: 1, resets_at: null } },
},
}),
environment,
);
assert.strictEqual(status.status, "ready");
assert.strictEqual(status.auth.status, "authenticated");
assert.strictEqual(status.auth.type, "customEndpoint");
assert.strictEqual(status.auth.label, "Custom API endpoint");
assert.strictEqual(status.usageLimits?.unavailable?.reason, "unsupported");
assert.deepStrictEqual(status.usageLimits?.windows, []);
}).pipe(
Effect.provide(
layerMockSpawner((args) => {
Expand Down Expand Up @@ -4186,7 +4220,7 @@ it.layer(
label: "Custom API endpoint",
},
])(
"uses the settings endpoint before the process endpoint: $type / $baseUrl",
"uses the project settings endpoint before user and process endpoints: $type / $baseUrl",
({ settings, baseUrl, type, label }) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand All @@ -4196,13 +4230,13 @@ it.layer(
});
yield* fileSystem.writeFileString(
path.join(configDir, "settings.json"),
JSON.stringify(settings),
JSON.stringify({ env: { ANTHROPIC_BASE_URL: "https://user.example" } }),
);
const stateDir = yield* fileSystem.makeTempDirectoryScoped();
yield* fileSystem.makeDirectory(path.join(stateDir, ".claude"));
yield* fileSystem.writeFileString(
path.join(stateDir, ".claude", "settings.json"),
JSON.stringify({ env: { ANTHROPIC_BASE_URL: "https://state-dir.example" } }),
JSON.stringify(settings),
);
const status = yield* checkClaudeProviderStatus(
{ ...defaultClaudeSettings, homePath: configDir },
Expand Down
Loading