mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-27 19:41:11 +00:00
fix(codex): re-home app-server usage reporting on the harness seam
The deleted text provider's usage hook was the only source for the /status Codex subscription usage line. Harnesses can now contribute an optional usage snapshot (provider hooks keep priority; only distinct synthetic hook owners fall through), and the codex harness reports app-server rate limits via account/rateLimits/read with the same conversion and account identity as before. No text provider resurrected; deadcode and SDK surface gates clean.
This commit is contained in:
@@ -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)) {
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,12 +116,91 @@ type CodexAppServerScopedRequest = <T = JsonValue | undefined>(request: {
|
||||
requestParams?: unknown;
|
||||
}) => Promise<T>;
|
||||
|
||||
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<typeof resolveCodexAppServerAuthProfileIdForAgent>[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<JsonValue>({ 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<string | undefined> {
|
||||
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<unknown>({ method: "account/read", requestParams: {} }).then(
|
||||
(account) => extractCodexAccountEmail(account),
|
||||
() => undefined,
|
||||
);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<undefined>((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<T>(
|
||||
async function withCodexAppServerJsonClient<T>(
|
||||
params: {
|
||||
timeoutMs?: number;
|
||||
timeoutMessage?: string;
|
||||
|
||||
67
extensions/codex/src/app-server/usage.test.ts
Normal file
67
extensions/codex/src/app-server/usage.test.ts
Normal file
@@ -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> = {},
|
||||
): 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();
|
||||
});
|
||||
});
|
||||
42
extensions/codex/src/app-server/usage.ts
Normal file
42
extensions/codex/src/app-server/usage.ts
Normal file
@@ -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<typeof readCodexAppServerUsage>[0],
|
||||
) => Promise<CodexAppServerUsageRead>;
|
||||
|
||||
/** Handles the synthetic usage credential for a Codex-backed OpenAI route. */
|
||||
export async function fetchCodexAppServerUsageSnapshot(
|
||||
ctx: ProviderFetchUsageSnapshotContext,
|
||||
options: {
|
||||
pluginConfig?: unknown;
|
||||
readUsage?: CodexAppServerUsageReader;
|
||||
} = {},
|
||||
): Promise<ProviderUsageSnapshot | null> {
|
||||
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;
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
87
src/plugins/provider-runtime.usage-harness.test.ts
Normal file
87
src/plugins/provider-runtime.usage-harness.test.ts
Normal file
@@ -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<typeof import("./provider-hook-runtime.js")>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user