diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 8458315f64de8..12d38705fe390 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -34,7 +34,11 @@ import { formatCodexModelName, } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; -import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; +import { + codexAppServerArgs, + codexUsageProbeDisabled, + resolveCodexLaunchArgs, +} from "./codexLaunchArgs.ts"; import { buildServerProvider, COMPACT_SLASH_COMMAND, @@ -555,7 +559,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun ), // Usage is an enrichment: a failure or a slow answer degrades to "no // usage this probe" rather than costing the account and models. - input.skipNativeUsage + input.skipNativeUsage || !accountResponse.requiresOpenaiAuth ? // @effect-diagnostics-next-line effectSucceedWithVoid:off - This optional result requires undefined, not the wider void type. Effect.succeed(undefined) : withCodexDiscoveryDeadline( @@ -703,6 +707,9 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); const emptyModels = emptyCodexModelsFromSettings(codexSettings); + const launchArgs = resolveCodexLaunchArgs(codexSettings.launchArgs, resolvedEnvironment); + const skipNativeUsageBeforeProbe = + managedAuth !== undefined || codexUsageProbeDisabled(launchArgs); if (!codexSettings.enabled) { return buildServerProvider({ @@ -724,10 +731,10 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu const probeResult = yield* probe({ binaryPath: codexSettings.binaryPath, homePath: codexSettings.homePath, - launchArgs: resolveCodexLaunchArgs(codexSettings.launchArgs, resolvedEnvironment), + launchArgs, cwd: process.cwd(), environment: resolvedEnvironment, - ...(managedAuth ? { skipNativeUsage: true } : {}), + ...(skipNativeUsageBeforeProbe ? { skipNativeUsage: true } : {}), }).pipe( // Process teardown can outlast the work budget without discarding a completed snapshot. Effect.timeoutOption(Duration.millis(CODEX_AUTH_PROBE_TIMEOUT_MS)), @@ -777,11 +784,13 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu } const snapshot = probeResult.success.value; + const skipNativeUsage = skipNativeUsageBeforeProbe || !snapshot.account.requiresOpenaiAuth; const accountStatus = managedAuth ? { status: "ready" as const, auth: managedAuth, message: undefined } : accountProbeStatus(snapshot); - const usageLimits = - snapshot.account.account?.type === "apiKey" + const usageLimits = skipNativeUsage + ? undefined + : snapshot.account.account?.type === "apiKey" ? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) : snapshot.rateLimits === undefined || "failure" in snapshot.rateLimits ? makeUnavailableUsageLimits({ @@ -831,7 +840,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu : snapshot.models === undefined ? { message: "Codex did not return its model catalog in time." } : {}), - ...(managedAuth ? {} : { usageLimits }), + ...(usageLimits ? { usageLimits } : {}), }, }); }); diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 1c47a2f31c3fe..7e92c85899097 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -381,6 +381,53 @@ it.layer( }), ); + it.effect("skips native usage probing for an external model provider", () => + Effect.gen(function* () { + let skipNativeUsage: boolean | undefined; + const settings: CodexSettings = { + ...defaultCodexSettings, + launchArgs: + "-c model_provider=local_pool -c model_providers.local_pool.requires_openai_auth=false", + }; + const status = yield* checkCodexProviderStatus(settings, (input) => { + skipNativeUsage = input.skipNativeUsage; + return Effect.succeed(makeCodexProbeSnapshot()); + }); + + assert.strictEqual(skipNativeUsage, true); + assert.isUndefined(status.usageLimits); + }), + ); + + it.effect("does not skip native usage for an unselected external provider", () => + Effect.gen(function* () { + let skipNativeUsage: boolean | undefined; + const settings: CodexSettings = { + ...defaultCodexSettings, + launchArgs: + "-c model_provider=openai -c model_providers.local_pool.requires_openai_auth=false", + }; + const status = yield* checkCodexProviderStatus(settings, (input) => { + skipNativeUsage = input.skipNativeUsage; + return Effect.succeed( + makeCodexProbeSnapshot({ + account: { + account: { + type: "chatgpt", + email: "test@example.com", + planType: "pro", + }, + requiresOpenaiAuth: true, + }, + }), + ); + }); + + assert.notStrictEqual(skipNativeUsage, true); + assert.isDefined(status.usageLimits); + }), + ); + it.effect("returns unauthenticated when app-server requires OpenAI auth", () => Effect.gen(function* () { const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => diff --git a/apps/server/src/provider/codexLaunchArgs.test.ts b/apps/server/src/provider/codexLaunchArgs.test.ts index ce18c8f3f1bd5..bcaf80f92ffd7 100644 --- a/apps/server/src/provider/codexLaunchArgs.test.ts +++ b/apps/server/src/provider/codexLaunchArgs.test.ts @@ -2,7 +2,11 @@ import * as NodeAssert from "node:assert/strict"; import { describe, it } from "vite-plus/test"; -import { codexAppServerArgs, codexExecLaunchArgs } from "./codexLaunchArgs.ts"; +import { + codexAppServerArgs, + codexExecLaunchArgs, + codexUsageProbeDisabled, +} from "./codexLaunchArgs.ts"; describe("codexAppServerArgs", () => { it("preserves escaped TOML quotes in config values", () => { @@ -27,3 +31,27 @@ describe("codexExecLaunchArgs", () => { ]); }); }); + +describe("codexUsageProbeDisabled", () => { + it("recognizes an external provider that does not use OpenAI auth", () => { + NodeAssert.equal( + codexUsageProbeDisabled( + "-c model_provider=local_pool -c model_providers.local_pool.requires_openai_auth=false --strict-config", + ), + true, + ); + }); + + it("ignores auth settings for providers that are not selected", () => { + NodeAssert.equal( + codexUsageProbeDisabled( + "-c model_provider=openai -c model_providers.local_pool.requires_openai_auth=false", + ), + false, + ); + }); + + it("keeps native usage probing enabled by default", () => { + NodeAssert.equal(codexUsageProbeDisabled('-c model="gpt-6.1-sol"'), false); + }); +}); diff --git a/apps/server/src/provider/codexLaunchArgs.ts b/apps/server/src/provider/codexLaunchArgs.ts index 33def5ce9a61a..17dac62473164 100644 --- a/apps/server/src/provider/codexLaunchArgs.ts +++ b/apps/server/src/provider/codexLaunchArgs.ts @@ -14,6 +14,45 @@ export const codexAppServerArgs = (launchArgs?: string) => [ ...codexLaunchArgv(launchArgs), ]; +function configValue( + argument: string | undefined, + nextArgument: string | undefined, +): string | undefined { + if (argument === "-c" || argument === "--config") return nextArgument; + if (argument?.startsWith("-c=")) return argument.slice(3); + if (argument?.startsWith("--config=")) return argument.slice("--config=".length); + return undefined; +} + +function configAssignment(value: string | undefined): readonly [string, string] | undefined { + if (!value) return undefined; + const separator = value.indexOf("="); + if (separator < 0) return undefined; + const key = value.slice(0, separator).trim(); + const assignment = value.slice(separator + 1).trim(); + if (!key || !assignment) return undefined; + return [key, assignment.replace(/^(['"])(.*)\1$/, "$2")]; +} + +/** + * External model providers do not implement Codex's OpenAI account quota RPC. + * A provider can opt out explicitly with `requires_openai_auth=false`. + */ +export const codexUsageProbeDisabled = (launchArgs?: string): boolean => { + const args = codexAppServerArgs(launchArgs); + const assignments = new Map(); + for (let index = 0; index < args.length; index++) { + const value = configValue(args[index], args[index + 1]); + const assignment = configAssignment(value); + if (assignment) assignments.set(assignment[0], assignment[1]); + } + const selectedProvider = assignments.get("model_provider") ?? "openai"; + return ( + assignments.get(`model_providers.${selectedProvider}.requires_openai_auth`)?.toLowerCase() === + "false" + ); +}; + export const codexExecLaunchArgs = (launchArgs?: string) => { const args = codexLaunchArgv(launchArgs); const execArgs: Array = []; diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 503521c087d72..5c4a99d1b9667 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -133,6 +133,180 @@ describe("pools", () => { expect(accounts[0]?.limits.windows[0]?.usedPercent).toBe(55); }); + it("keeps duplicate hub credentials with one email as separate pool members", () => { + const accounts = ["first.json", "second.json"].map((id, index) => ({ + id, + driver: claude, + email: "same@example.com", + usageLimits: { + checkedAt, + windows: [{ ...window, usedPercent: index === 0 ? 10 : 80 }], + }, + })); + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { providers: [], usageLimitSources: [{ ...source, accounts }] }, + }, + ], + ]); + + const limitAccounts = collectLimitAccounts(input); + expect(limitAccounts.map((account) => account.key)).toEqual([ + "hub:first.json", + "hub:second.json", + ]); + expect(collectLimitPools(limitAccounts, now)[0]?.windows[0]).toMatchObject({ + usedPercent: 45, + members: [{ account: { key: "hub:first.json" } }, { account: { key: "hub:second.json" } }], + }); + }); + + it("keeps a workspace-less hub credential apart from a different workspace credential", () => { + const accounts = [ + { + id: "workspace.json", + driver: claude, + email: "same@example.com", + workspaceId: "workspace-a", + usageLimits: { + checkedAt, + windows: [{ ...window, usedPercent: 20 }], + }, + }, + { + id: "personal.json", + driver: claude, + email: "same@example.com", + usageLimits: { + checkedAt, + windows: [{ ...window, usedPercent: 70 }], + }, + }, + ]; + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { providers: [], usageLimitSources: [{ ...source, accounts }] }, + }, + ], + ]); + + const limitAccounts = collectLimitAccounts(input); + expect(limitAccounts.map((account) => account.key)).toEqual([ + "hub:workspace.json:workspace-a", + "hub:personal.json", + ]); + expect(limitAccounts.map((account) => account.limits.windows[0]?.usedPercent)).toEqual([ + 20, 70, + ]); + }); + + it("merges mixed workspace reports for one source credential across environments", () => { + const account = ( + id: string, + workspaceId?: string, + usedPercent = 20, + reportCheckedAt = checkedAt, + ) => ({ + id, + driver: claude, + email: "same@example.com", + ...(workspaceId ? { workspaceId } : {}), + usageLimits: { checkedAt: reportCheckedAt, windows: [{ ...window, usedPercent }] }, + }); + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [], + usageLimitSources: [ + { + ...source, + accounts: [ + account("first.json", "workspace-a"), + account("second.json", "workspace-a", 80), + ], + }, + ], + }, + }, + ], + [ + EnvironmentId.make("env-b"), + { + entry: { target: { label: "Desktop" } }, + serverConfig: { + providers: [], + usageLimitSources: [ + { + ...source, + accounts: [account("first.json", undefined, 30, "2026-09-03T11:30:00.000Z")], + }, + ], + }, + }, + ], + ]); + + const accounts = collectLimitAccounts(input); + expect(accounts.map((limitAccount) => limitAccount.key)).toEqual([ + "hub:first.json:workspace-a", + "hub:second.json:workspace-a", + ]); + expect(accounts[0]?.limits.windows[0]?.usedPercent).toBe(30); + expect(accounts[1]?.limits.windows[0]?.usedPercent).toBe(80); + }); + + it("deduplicates native reports without joining source credentials", () => { + const native = provider({ + driver: claude, + instanceId: ProviderInstanceId.make("claude"), + auth: { status: "authenticated", email: "same@example.com" }, + usageLimits: { checkedAt, windows: [{ ...window, usedPercent: 30 }] }, + }); + const accounts = ["first.json", "second.json"].map((id, index) => ({ + id, + driver: claude, + email: "same@example.com", + usageLimits: { checkedAt, windows: [{ ...window, usedPercent: index === 0 ? 10 : 90 }] }, + })); + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { providers: [native], usageLimitSources: [{ ...source, accounts }] }, + }, + ], + [ + EnvironmentId.make("env-b"), + { + entry: { target: { label: "Desktop" } }, + serverConfig: { + providers: [{ ...native, instanceId: ProviderInstanceId.make("codex-2") }], + }, + }, + ], + ]); + + expect(collectLimitAccounts(input).map((account) => account.key)).toEqual([ + "env-a:claude", + "hub:first.json", + "hub:second.json", + ]); + expect(collectLimitAccounts(input)[0]?.environments).toEqual([ + { environmentId: "env-a", label: "Laptop" }, + { environmentId: "env-b", label: "Desktop" }, + ]); + }); + it("merges OpenCode Go limits from machines with the same API key", () => { const go = provider({ driver: ProviderDriverKind.make("opencode"), diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 01e2ad59a332d..b19184e124f32 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -121,6 +121,9 @@ interface AccountReport { readonly email?: string | undefined; readonly workspaceId?: string | undefined; readonly usageLimits?: ServerProviderUsageLimits | undefined; + /** Identifies one credential in a usage-limit source. Native providers omit it. */ + readonly sourceId?: string | undefined; + readonly sourceAccountId?: string | undefined; } const nativeReport = (provider: ServerProvider): AccountReport => ({ @@ -130,21 +133,75 @@ const nativeReport = (provider: ServerProvider): AccountReport => ({ usageLimits: provider.usageLimits, }); +const sourceReport = ( + sourceId: string, + account: Pick & { + readonly id: string; + }, +): AccountReport => ({ + driver: account.driver, + email: account.email, + workspaceId: account.workspaceId, + usageLimits: account.usageLimits, + sourceId, + sourceAccountId: account.id, +}); + // Include every enabled login, even unreadable workspaces. function accountKeyResolver(reports: Iterable) { const workspaces = new Map>(); + const sourceAccounts = new Map>(); + const sourceWorkspaceAccounts = new Map>(); for (const report of reports) { const key = accountKey(report.driver, report.email, report.usageLimits); if (key && report.workspaceId) { workspaces.set(key, (workspaces.get(key) ?? new Set()).add(report.workspaceId)); } + if (key && report.sourceId && report.sourceAccountId) { + const sourceIdentity = `${report.sourceId}:${report.sourceAccountId}`; + if (report.workspaceId) { + const workspaceKey = `${key}:${report.workspaceId}`; + sourceWorkspaceAccounts.set( + workspaceKey, + (sourceWorkspaceAccounts.get(workspaceKey) ?? new Set()).add(sourceIdentity), + ); + } else { + sourceAccounts.set(key, (sourceAccounts.get(key) ?? new Set()).add(sourceIdentity)); + } + } } return (report: AccountReport): string | null => { const key = accountKey(report.driver, report.email, report.usageLimits); if (!key) return null; - if (report.workspaceId) return `${key}:${report.workspaceId}`; + const sourceIdentity = + report.sourceId && report.sourceAccountId + ? `${report.sourceId}:${report.sourceAccountId}` + : undefined; + if (report.workspaceId) { + const workspaceKey = `${key}:${report.workspaceId}`; + if (sourceIdentity && (sourceWorkspaceAccounts.get(workspaceKey)?.size ?? 0) > 1) { + return `${workspaceKey}:source:${sourceIdentity}`; + } + return workspaceKey; + } + if (sourceIdentity && (sourceAccounts.get(key)?.size ?? 0) > 1) { + return `${key}:source:${sourceIdentity}`; + } + // Multiple source credentials share this email but are distinct accounts. Keep a + // native provider without a workspace separate instead of attaching it to all of them. + if (!sourceIdentity && (sourceAccounts.get(key)?.size ?? 0) > 1) return `${key}:native`; const known = workspaces.get(key); if (!known) return key; + if (sourceIdentity && known.size === 1) { + const workspaceId = [...known][0]; + const workspaceSources = sourceWorkspaceAccounts.get(`${key}:${workspaceId}`); + if (workspaceSources && workspaceSources.size > 1 && workspaceSources.has(sourceIdentity)) { + return `${key}:${workspaceId}:source:${sourceIdentity}`; + } + if (workspaceSources && !workspaceSources.has(sourceIdentity)) { + return `${key}:source:${sourceIdentity}`; + } + } return known.size === 1 ? `${key}:${[...known][0]}` : null; }; } @@ -228,7 +285,9 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl ...(presentation.serverConfig?.providers ?? []) .filter((provider) => provider.enabled) .map(nativeReport), - ...(presentation.serverConfig?.usageLimitSources ?? []).flatMap((source) => source.accounts), + ...(presentation.serverConfig?.usageLimitSources ?? []).flatMap((source) => + source.accounts.map((account) => sourceReport(source.id, account)), + ), ]), ); for (const [environmentId, presentation] of presentations) { @@ -258,7 +317,7 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl for (const account of source.accounts) { if (limitsNotice(account.usageLimits) !== null) continue; const key = `${source.id}:${account.id}${account.workspaceId ? `:${account.workspaceId}` : ""}`; - merge(resolveKey(account) ?? key, { + merge(resolveKey(sourceReport(source.id, account)) ?? key, { key, driver: account.driver, displayName: account.email ? null : account.id.replace(/\.json$/i, ""), @@ -656,7 +715,9 @@ export function collectProviderUsageLimits( if (!selected || !hasProviderUsageLimits(selected.driver, providers, sources)) return null; const resolveKey = accountKeyResolver([ ...providers.filter((provider) => provider.enabled).map(nativeReport), - ...sources.flatMap((source) => source.accounts), + ...sources.flatMap((source) => + source.accounts.map((account) => sourceReport(source.id, account)), + ), ]); type NativeSnapshot = { provider: ServerProvider; limits: ServerProviderUsageLimits }; const nativeByAccount = new Map< @@ -704,9 +765,9 @@ export function collectProviderUsageLimits( const hubCredits = sources .flatMap((source) => source.accounts.map((account) => ({ source, account }))) .filter( - ({ account }) => + ({ source, account }) => key !== null && - resolveKey(account) === key && + resolveKey(sourceReport(source.id, account)) === key && account.usageLimits.resetCredits && !limitsNotice(account.usageLimits), ) @@ -746,7 +807,7 @@ export function collectProviderUsageLimits( for (const source of sources) { const matching = source.accounts.filter((account) => account.driver === selected.driver); for (const account of matching) { - const key = resolveKey(account); + const key = resolveKey(sourceReport(source.id, account)); if (key && nativeAccountKeys.has(key)) continue; accounts.push({ id: `${source.id}:${account.id}`,