diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index b657a2b813feb..2d40a7b3167d1 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -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; + if (!capabilities) { return buildServerProvider({ presentation: CLAUDE_PRESENTATION, @@ -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; @@ -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 && diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 4fdd3565d11b6..ff8403cc08ccf 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -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, cwd: string | undefined, @@ -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)); diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index c42d78b237d42..ad517757ed0f6 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -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"; @@ -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"; @@ -187,6 +188,7 @@ type TestClaudeCapabilities = { readonly apiKeySource: string | undefined; readonly apiProvider: string | undefined; readonly slashCommands: ReadonlyArray; + readonly usage?: NonNullable; readonly models?: ReadonlyArray; }; @@ -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: [] }, { @@ -4148,6 +4176,10 @@ it.layer( tokenSource: "none", apiKeySource: "none", apiProvider: "firstParty", + usage: { + rate_limits_available: true, + rate_limits: { five_hour: { utilization: 1, resets_at: null } }, + }, }), environment, ); @@ -4155,6 +4187,8 @@ it.layer( 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) => { @@ -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; @@ -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 },