diff --git a/extensions/codex/harness.ts b/extensions/codex/harness.ts index 1dcdcb686933..dc48773e90e2 100644 --- a/extensions/codex/harness.ts +++ b/extensions/codex/harness.ts @@ -90,6 +90,12 @@ export function createCodexAppServerAgentHarness(options: { return validateCodexAppServerRuntimeArtifact(binding); }, }, + fetchUsageSnapshot: async (ctx) => { + const { fetchCodexAppServerUsageSnapshot } = await import("./src/app-server/usage.js"); + return await fetchCodexAppServerUsageSnapshot(ctx, { + pluginConfig: options?.resolvePluginConfig?.() ?? options?.pluginConfig, + }); + }, supports: (ctx) => { const provider = ctx.provider.trim().toLowerCase(); if (!providerIds.has(provider)) { diff --git a/extensions/codex/index.test.ts b/extensions/codex/index.test.ts index f62e7fdc0105..c476b662d982 100644 --- a/extensions/codex/index.test.ts +++ b/extensions/codex/index.test.ts @@ -124,6 +124,7 @@ describe("codex plugin", () => { sourceVisibleReplies: "message_tool", }); expect(typeof agentHarnessRegistration.dispose).toBe("function"); + expect(typeof agentHarnessRegistration.fetchUsageSnapshot).toBe("function"); expect(mediaProviderRegistration?.id).toBe("codex"); expect(mediaProviderRegistration?.capabilities).toEqual(["image"]); expect(mediaProviderRegistration?.defaultModels).toEqual({ image: "gpt-5.6-sol" }); diff --git a/extensions/codex/src/app-server/request.test.ts b/extensions/codex/src/app-server/request.test.ts index d65c48f8325c..b7e2c4a11a7e 100644 --- a/extensions/codex/src/app-server/request.test.ts +++ b/extensions/codex/src/app-server/request.test.ts @@ -20,7 +20,7 @@ vi.mock("./shared-client.js", () => ({ getLeasedSharedCodexAppServerClient: sharedClientMocks.getSharedCodexAppServerClient, })); -const { requestCodexAppServerJson } = await import("./request.js"); +const { readCodexAppServerUsage, requestCodexAppServerJson } = await import("./request.js"); const expectDeadlineOptions = () => expect.objectContaining({ timeoutMs: expect.any(Number), signal: expect.anything() }); @@ -354,4 +354,41 @@ describe("requestCodexAppServerJson sandbox guard", () => { expect(sharedClientMocks.getSharedCodexAppServerClient).not.toHaveBeenCalled(); }); + + it("reads usage and account identity over one isolated client", async () => { + const request = vi.fn(async (method: string) => + method === "account/rateLimits/read" + ? { rateLimitsByLimitId: { codex: { limitId: "codex" } } } + : { account: { email: "codex-account@example.com" } }, + ); + const closeAndWait = vi.fn(async () => undefined); + sharedClientMocks.createIsolatedCodexAppServerClient.mockResolvedValue({ + request, + closeAndWait, + }); + + await expect( + readCodexAppServerUsage({ + timeoutMs: 3_500, + authProfileId: "openai:test", + }), + ).resolves.toEqual({ + rateLimits: { rateLimitsByLimitId: { codex: { limitId: "codex" } } }, + accountEmail: "codex-account@example.com", + }); + expect(sharedClientMocks.createIsolatedCodexAppServerClient).toHaveBeenCalledWith( + expect.objectContaining({ + authProfileId: "openai:test", + timeoutMs: expect.any(Number), + }), + ); + expect(request).toHaveBeenNthCalledWith( + 1, + "account/rateLimits/read", + undefined, + expectDeadlineOptions(), + ); + expect(request).toHaveBeenNthCalledWith(2, "account/read", {}, expectDeadlineOptions()); + expect(closeAndWait).toHaveBeenCalledWith({ exitTimeoutMs: 300, forceKillDelayMs: 200 }); + }); }); diff --git a/extensions/codex/src/app-server/request.ts b/extensions/codex/src/app-server/request.ts index fbac58f9765b..803adaa6bfe3 100644 --- a/extensions/codex/src/app-server/request.ts +++ b/extensions/codex/src/app-server/request.ts @@ -116,12 +116,91 @@ type CodexAppServerScopedRequest = (request: { requestParams?: unknown; }) => Promise; +const CODEX_USAGE_ISOLATED_SHUTDOWN = { forceKillDelayMs: 200, exitTimeoutMs: 300 } as const; +const CODEX_ACCOUNT_READ_MAX_TIMEOUT_MS = 4_000; +const CODEX_ACCOUNT_READ_DEADLINE_MARGIN_MS = 250; +const CODEX_USAGE_DEADLINE_RESERVE_MS = + CODEX_USAGE_ISOLATED_SHUTDOWN.forceKillDelayMs + + CODEX_USAGE_ISOLATED_SHUTDOWN.exitTimeoutMs + + CODEX_ACCOUNT_READ_DEADLINE_MARGIN_MS; + +/** Reads rate limits and best-effort account identity from one isolated app-server session. */ +export async function readCodexAppServerUsage(options: { + timeoutMs: number; + agentDir?: string; + authProfileId?: string; + config?: Parameters[0]["config"]; + startOptions?: CodexAppServerStartOptions; +}): Promise<{ rateLimits: JsonValue; accountEmail?: string }> { + const deadline = Date.now() + options.timeoutMs; + return await withCodexAppServerJsonClient( + { + timeoutMs: options.timeoutMs, + timeoutMessage: "codex app-server usage read timed out", + agentDir: options.agentDir, + ...(options.authProfileId ? { authProfileId: options.authProfileId } : {}), + config: options.config, + startOptions: options.startOptions, + isolated: true, + // A throwaway read-only child: bound shutdown inside the outer usage deadline. + isolatedShutdown: CODEX_USAGE_ISOLATED_SHUTDOWN, + }, + async (request) => { + const rateLimits = await request({ method: "account/rateLimits/read" }); + const accountEmail = await readCodexAccountEmailBestEffort(request, deadline); + return { rateLimits, ...(accountEmail ? { accountEmail } : {}) }; + }, + ); +} + +function extractCodexAccountEmail(value: unknown): string | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const record = value as { account?: unknown; email?: unknown; accountEmail?: unknown }; + const account = + record.account && typeof record.account === "object" + ? (record.account as { email?: unknown; accountEmail?: unknown }) + : record; + const email = account.email ?? account.accountEmail; + return typeof email === "string" && email.trim() ? email.trim() : undefined; +} + +async function readCodexAccountEmailBestEffort( + request: CodexAppServerScopedRequest, + deadline: number, +): Promise { + const boundMs = Math.min( + CODEX_ACCOUNT_READ_MAX_TIMEOUT_MS, + deadline - Date.now() - CODEX_USAGE_DEADLINE_RESERVE_MS, + ); + if (boundMs <= 0) { + return undefined; + } + const read = request({ method: "account/read", requestParams: {} }).then( + (account) => extractCodexAccountEmail(account), + () => undefined, + ); + let timer: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), boundMs); + timer.unref?.(); + }); + try { + return await Promise.race([read, timeout]); + } finally { + if (timer) { + clearTimeout(timer); + } + } +} + /** * Runs several guarded requests over one acquired client (shared lease or * isolated child) so related reads see the same app-server session. The whole * callback re-runs once when the client's start selection changed underneath it. */ -export async function withCodexAppServerJsonClient( +async function withCodexAppServerJsonClient( params: { timeoutMs?: number; timeoutMessage?: string; diff --git a/extensions/codex/src/app-server/usage.test.ts b/extensions/codex/src/app-server/usage.test.ts new file mode 100644 index 000000000000..aee2d37cbcdc --- /dev/null +++ b/extensions/codex/src/app-server/usage.test.ts @@ -0,0 +1,67 @@ +// Codex usage tests cover the harness-owned provider-usage contribution. +import type { ProviderFetchUsageSnapshotContext } from "openclaw/plugin-sdk/plugin-entry"; +import { CODEX_APP_SERVER_AUTH_MARKER } from "openclaw/plugin-sdk/provider-usage"; +import { describe, expect, it, vi } from "vitest"; +import { fetchCodexAppServerUsageSnapshot } from "./usage.js"; + +function usageContext( + overrides: Partial = {}, +): ProviderFetchUsageSnapshotContext { + return { + config: {}, + env: {}, + provider: "openai", + token: CODEX_APP_SERVER_AUTH_MARKER, + timeoutMs: 3_500, + fetchFn: fetch, + ...overrides, + }; +} + +describe("Codex app-server provider usage", () => { + it("contributes OpenAI usage windows for the synthetic app-server credential", async () => { + const readUsage = vi.fn(async () => ({ + rateLimits: { + rateLimitsByLimitId: { + codex: { + limitId: "codex", + primary: { + usedPercent: 9, + windowDurationMins: 300, + resetsAt: 1_700_003_600, + }, + }, + }, + }, + accountEmail: "codex-account@example.com", + })); + + await expect(fetchCodexAppServerUsageSnapshot(usageContext(), { readUsage })).resolves.toEqual({ + provider: "openai", + displayName: "OpenAI", + windows: [{ label: "5h", usedPercent: 9, resetAt: 1_700_003_600_000 }], + plan: undefined, + accountEmail: "codex-account@example.com", + }); + expect(readUsage).toHaveBeenCalledWith({ + timeoutMs: 3_500, + agentDir: undefined, + config: {}, + startOptions: expect.objectContaining({ + command: "codex", + commandSource: "managed", + }), + }); + }); + + it("ignores ordinary OpenAI credentials", async () => { + const readUsage = vi.fn(); + + await expect( + fetchCodexAppServerUsageSnapshot(usageContext({ token: "test-token-placeholder" }), { + readUsage, + }), + ).resolves.toBeNull(); + expect(readUsage).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/codex/src/app-server/usage.ts b/extensions/codex/src/app-server/usage.ts new file mode 100644 index 000000000000..4c9b8cfecc53 --- /dev/null +++ b/extensions/codex/src/app-server/usage.ts @@ -0,0 +1,42 @@ +/** Builds provider-usage snapshots from the Codex app-server account surface. */ +import type { ProviderFetchUsageSnapshotContext } from "openclaw/plugin-sdk/plugin-entry"; +import { + CODEX_APP_SERVER_AUTH_MARKER, + type ProviderUsageSnapshot, +} from "openclaw/plugin-sdk/agent-runtime"; +import { resolveCodexAppServerRuntimeOptions } from "./config.js"; +import { buildCodexAppServerUsageSnapshot } from "./rate-limits.js"; +import { readCodexAppServerUsage } from "./request.js"; + +type CodexAppServerUsageRead = { + rateLimits: unknown; + accountEmail?: string; +}; + +type CodexAppServerUsageReader = ( + options: Parameters[0], +) => Promise; + +/** Handles the synthetic usage credential for a Codex-backed OpenAI route. */ +export async function fetchCodexAppServerUsageSnapshot( + ctx: ProviderFetchUsageSnapshotContext, + options: { + pluginConfig?: unknown; + readUsage?: CodexAppServerUsageReader; + } = {}, +): Promise { + if (ctx.token !== CODEX_APP_SERVER_AUTH_MARKER) { + return null; + } + const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: options.pluginConfig }); + const usage = await (options.readUsage ?? readCodexAppServerUsage)({ + timeoutMs: ctx.timeoutMs, + agentDir: ctx.agentDir, + ...(ctx.authProfileId ? { authProfileId: ctx.authProfileId } : {}), + config: ctx.config, + startOptions: appServer.start, + }); + const snapshot = buildCodexAppServerUsageSnapshot(usage.rateLimits); + const accountEmail = ctx.email ?? usage.accountEmail; + return accountEmail && !snapshot.error ? { ...snapshot, accountEmail } : snapshot; +} diff --git a/src/agents/harness/types.ts b/src/agents/harness/types.ts index 83057c3fe7b5..df3d297add67 100644 --- a/src/agents/harness/types.ts +++ b/src/agents/harness/types.ts @@ -196,12 +196,29 @@ type AgentHarnessAuthBindingCapability = { }; }; +type AgentHarnessProviderUsageCapability = { + /** + * Contributes runtime-owned quota data without registering a text provider. + * Provider usage hooks remain authoritative when both surfaces exist. + */ + fetchUsageSnapshot?: ( + ctx: import("../../plugins/provider-runtime.types.js").ProviderFetchUsageSnapshotContext, + ) => + | Promise< + import("../../infra/provider-usage.types.js").ProviderUsageSnapshot | null | undefined + > + | import("../../infra/provider-usage.types.js").ProviderUsageSnapshot + | null + | undefined; +}; + export type AgentHarness = AgentHarnessRunCapability & AgentHarnessSideQuestionCapability & AgentHarnessClassificationCapability & AgentHarnessCompactionCapability & AgentHarnessRuntimeArtifactCapability & AgentHarnessAuthBindingCapability & + AgentHarnessProviderUsageCapability & AgentHarnessSessionLifecycleCapability; export type RegisteredAgentHarness = { diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 25a3335e8ade..339a6fcc84fc 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -11,6 +11,7 @@ import { import { sanitizeForLog } from "../../packages/terminal-core/src/ansi.js"; import type { AuthProfileCredential, OAuthCredential } from "../agents/auth-profiles/types.js"; import { resolveGpt5SystemPromptContribution } from "../agents/gpt5-prompt-overlay.js"; +import { getRegisteredAgentHarness } from "../agents/harness/registry.js"; import { applyPluginTextReplacements, mergePluginTextTransforms, @@ -688,7 +689,36 @@ export async function resolveProviderUsageSnapshotWithPlugin(params: { env?: NodeJS.ProcessEnv; context: ProviderFetchUsageSnapshotContext; }) { - return await resolveProviderRuntimePlugin(params)?.fetchUsageSnapshot?.(params.context); + const providerHook = resolveProviderRuntimePlugin(params)?.fetchUsageSnapshot; + if (providerHook) { + const snapshot = await providerHook(params.context); + if (snapshot != null) { + return snapshot; + } + } + + // A distinct hook owner is an explicit synthetic contribution route. Avoid + // probing harness manifests for ordinary provider usage misses. + if (params.provider === params.context.provider) { + return undefined; + } + + let harness = getRegisteredAgentHarness(params.provider)?.harness; + if (!harness) { + const workspaceDir = + params.workspaceDir ?? getActivePluginRegistryWorkspaceDirFromState() ?? process.cwd(); + const { ensureSelectedAgentHarnessPlugin } = + await import("../agents/harness/runtime-plugin.js"); + await ensureSelectedAgentHarnessPlugin({ + provider: params.context.provider, + modelId: "", + config: params.config, + agentHarnessId: params.provider, + workspaceDir, + }); + harness = getRegisteredAgentHarness(params.provider)?.harness; + } + return await harness?.fetchUsageSnapshot?.(params.context); } export type ProviderUsagePluginDescriptor = { diff --git a/src/plugins/provider-runtime.usage-harness.test.ts b/src/plugins/provider-runtime.usage-harness.test.ts new file mode 100644 index 000000000000..f880a6a75815 --- /dev/null +++ b/src/plugins/provider-runtime.usage-harness.test.ts @@ -0,0 +1,87 @@ +// Verifies provider usage can be contributed by a runtime harness without a text provider. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { clearAgentHarnesses, registerAgentHarness } from "../agents/harness/registry.js"; +import { resolveProviderUsageSnapshotWithPlugin } from "./provider-runtime.js"; + +vi.mock("./provider-hook-runtime.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, resolveProviderRuntimePlugin: () => undefined }; +}); + +describe("provider runtime harness usage", () => { + afterEach(() => { + clearAgentHarnesses(); + }); + + it("routes a synthetic hook id to the matching harness", async () => { + const fetchUsageSnapshot = vi.fn(async () => ({ + provider: "openai" as const, + displayName: "OpenAI", + windows: [{ label: "5h", usedPercent: 9 }], + })); + registerAgentHarness({ + id: "codex", + label: "Codex", + supports: () => ({ supported: true }), + runAttempt: async () => { + throw new Error("not used"); + }, + fetchUsageSnapshot, + }); + + await expect( + resolveProviderUsageSnapshotWithPlugin({ + provider: "codex", + config: {}, + env: {}, + workspaceDir: process.cwd(), + context: { + config: {}, + env: {}, + provider: "openai", + token: "test-token-placeholder", + timeoutMs: 5_000, + fetchFn: fetch, + }, + }), + ).resolves.toEqual({ + provider: "openai", + displayName: "OpenAI", + windows: [{ label: "5h", usedPercent: 9 }], + }); + expect(fetchUsageSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ provider: "openai", token: "test-token-placeholder" }), + ); + }); + + it("does not probe a harness for an ordinary provider usage miss", async () => { + const fetchUsageSnapshot = vi.fn(); + registerAgentHarness({ + id: "openai", + label: "OpenAI harness", + supports: () => ({ supported: true }), + runAttempt: async () => { + throw new Error("not used"); + }, + fetchUsageSnapshot, + }); + + await expect( + resolveProviderUsageSnapshotWithPlugin({ + provider: "openai", + config: {}, + env: {}, + workspaceDir: process.cwd(), + context: { + config: {}, + env: {}, + provider: "openai", + token: "test-token-placeholder", + timeoutMs: 5_000, + fetchFn: fetch, + }, + }), + ).resolves.toBeUndefined(); + expect(fetchUsageSnapshot).not.toHaveBeenCalled(); + }); +});