diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 97512d3d621f0..1d33fd50471a8 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -609,6 +609,17 @@ 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 endpoint = URL.parse(baseUrl?.trim() ?? ""); + const customApiEndpoint = + endpoint !== null && + (endpoint.protocol === "http:" || endpoint.protocol === "https:") && + endpoint.hostname !== "" && + endpoint.hostname !== "api.anthropic.com"; + if (!capabilities) { return buildServerProvider({ presentation: CLAUDE_PRESENTATION, @@ -628,20 +639,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ? `Timed out after ${CAPABILITIES_PROBE_TIMEOUT_MS / 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 endpoint = URL.parse(baseUrl?.trim() ?? ""); - const customApiEndpoint = - endpoint !== null && - (endpoint.protocol === "http:" || endpoint.protocol === "https:") && - endpoint.hostname !== "" && - endpoint.hostname !== "api.anthropic.com"; if (!customApiEndpoint && claudeAuthStatus(capabilities) === "unauthenticated") { return buildServerProvider({ presentation: CLAUDE_PRESENTATION, @@ -670,17 +674,20 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( authMethod: capabilities.tokenSource, }); 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/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index e318a3c79a66e..34b84a92f8e5d 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"; @@ -46,7 +47,7 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import { checkCodexProviderStatus, 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"; @@ -177,6 +178,7 @@ type TestClaudeCapabilities = { readonly apiKeySource: string | undefined; readonly apiProvider: string | undefined; readonly slashCommands: ReadonlyArray; + readonly usage?: NonNullable; readonly models?: ReadonlyArray; }; @@ -3167,6 +3169,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.UnknownError("probe failed")), + { 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: [] }, { @@ -3187,6 +3215,10 @@ it.layer( tokenSource: "none", apiKeySource: "none", apiProvider: "firstParty", + usage: { + rate_limits_available: true, + rate_limits: { five_hour: { utilization: 1, resets_at: null } }, + }, }), environment, ); @@ -3194,6 +3226,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) => {