mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 12:30:44 +00:00
fix: isolate Codex ACP auth
This commit is contained in:
109
extensions/acpx/src/codex-auth-bridge.test.ts
Normal file
109
extensions/acpx/src/codex-auth-bridge.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { prepareAcpxCodexAuthConfig } from "./codex-auth-bridge.js";
|
||||
import { resolveAcpxPluginConfig } from "./config.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const previousEnv = {
|
||||
CODEX_HOME: process.env.CODEX_HOME,
|
||||
OPENCLAW_AGENT_DIR: process.env.OPENCLAW_AGENT_DIR,
|
||||
PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR,
|
||||
};
|
||||
|
||||
async function makeTempDir(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acpx-codex-auth-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function restoreEnv(name: keyof typeof previousEnv): void {
|
||||
const value = previousEnv[name];
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function unquoteCommandPath(command: string): string {
|
||||
return command.replace(/^'|'$/g, "").replace(/'\\''/g, "'");
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
restoreEnv("CODEX_HOME");
|
||||
restoreEnv("OPENCLAW_AGENT_DIR");
|
||||
restoreEnv("PI_CODING_AGENT_DIR");
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("prepareAcpxCodexAuthConfig", () => {
|
||||
it("wraps built-in Codex ACP with an isolated CODEX_HOME copy", async () => {
|
||||
const root = await makeTempDir();
|
||||
const sourceCodexHome = path.join(root, "source-codex");
|
||||
const agentDir = path.join(root, "agent");
|
||||
const stateDir = path.join(root, "state");
|
||||
await fs.mkdir(sourceCodexHome, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(sourceCodexHome, "auth.json"),
|
||||
`${JSON.stringify({ auth_mode: "apikey", OPENAI_API_KEY: "test-api-key" }, null, 2)}\n`,
|
||||
);
|
||||
await fs.writeFile(path.join(sourceCodexHome, "config.toml"), 'model = "gpt-5.4"\n');
|
||||
process.env.CODEX_HOME = sourceCodexHome;
|
||||
process.env.OPENCLAW_AGENT_DIR = agentDir;
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {},
|
||||
workspaceDir: root,
|
||||
});
|
||||
const resolved = await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
});
|
||||
|
||||
const wrapperPath = unquoteCommandPath(resolved.agents.codex ?? "");
|
||||
expect(wrapperPath).toBe(path.join(stateDir, "acpx", "codex-acp-wrapper.mjs"));
|
||||
await expect(fs.access(wrapperPath)).resolves.toBeUndefined();
|
||||
|
||||
const isolatedAuthPath = path.join(agentDir, "acp-auth", "codex-source", "auth.json");
|
||||
const copiedAuth = JSON.parse(await fs.readFile(isolatedAuthPath, "utf8")) as {
|
||||
auth_mode?: string;
|
||||
OPENAI_API_KEY?: string;
|
||||
};
|
||||
expect(copiedAuth).toEqual({ auth_mode: "apikey", OPENAI_API_KEY: "test-api-key" });
|
||||
expect((await fs.stat(isolatedAuthPath)).mode & 0o777).toBe(0o600);
|
||||
await expect(
|
||||
fs.readFile(path.join(agentDir, "acp-auth", "codex-source", "config.toml"), "utf8"),
|
||||
).resolves.toBe('model = "gpt-5.4"\n');
|
||||
|
||||
const wrapper = await fs.readFile(wrapperPath, "utf8");
|
||||
expect(wrapper).toContain(`CODEX_HOME: ${JSON.stringify(path.dirname(isolatedAuthPath))}`);
|
||||
expect(wrapper).toContain("delete env[key]");
|
||||
expect(wrapper).not.toContain("test-api-key");
|
||||
});
|
||||
|
||||
it("does not override an explicitly configured Codex agent command", async () => {
|
||||
const root = await makeTempDir();
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
agents: {
|
||||
codex: {
|
||||
command: "custom-codex-acp",
|
||||
},
|
||||
},
|
||||
},
|
||||
workspaceDir: root,
|
||||
});
|
||||
|
||||
const resolved = await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir: path.join(root, "state"),
|
||||
});
|
||||
|
||||
expect(resolved.agents.codex).toBe("custom-codex-acp");
|
||||
});
|
||||
});
|
||||
157
extensions/acpx/src/codex-auth-bridge.ts
Normal file
157
extensions/acpx/src/codex-auth-bridge.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resolveOpenClawAgentDir } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { prepareCodexAuthBridge } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import { writePrivateSecretFileAtomic } from "openclaw/plugin-sdk/secret-file-runtime";
|
||||
import type { PluginLogger } from "../runtime-api.js";
|
||||
import type { ResolvedAcpxPluginConfig } from "./config.js";
|
||||
|
||||
const CODEX_AGENT_ID = "codex";
|
||||
const DEFAULT_CODEX_AUTH_PROFILE_ID = "openai-codex:default";
|
||||
const CODEX_AUTH_ENV_CLEAR_KEYS = ["OPENAI_API_KEY"];
|
||||
|
||||
type PreparedAcpxCodexAuth = {
|
||||
codexHome: string;
|
||||
clearEnv: string[];
|
||||
};
|
||||
|
||||
function resolveSourceCodexHome(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const configured = env.CODEX_HOME?.trim();
|
||||
if (configured) {
|
||||
if (configured === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
if (configured.startsWith("~/")) {
|
||||
return path.join(os.homedir(), configured.slice(2));
|
||||
}
|
||||
return path.resolve(configured);
|
||||
}
|
||||
return path.join(os.homedir(), ".codex");
|
||||
}
|
||||
|
||||
async function readOptionalFile(filePath: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await fs.readFile(filePath, "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareCopiedCodexHome(params: {
|
||||
agentDir: string;
|
||||
sourceCodexHome: string;
|
||||
}): Promise<PreparedAcpxCodexAuth | null> {
|
||||
const authJson = await readOptionalFile(path.join(params.sourceCodexHome, "auth.json"));
|
||||
if (!authJson) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const codexHome = path.join(params.agentDir, "acp-auth", "codex-source");
|
||||
await writePrivateSecretFileAtomic({
|
||||
rootDir: params.agentDir,
|
||||
filePath: path.join(codexHome, "auth.json"),
|
||||
content: authJson,
|
||||
});
|
||||
|
||||
const configToml = await readOptionalFile(path.join(params.sourceCodexHome, "config.toml"));
|
||||
if (configToml) {
|
||||
await writePrivateSecretFileAtomic({
|
||||
rootDir: params.agentDir,
|
||||
filePath: path.join(codexHome, "config.toml"),
|
||||
content: configToml,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
codexHome,
|
||||
clearEnv: [...CODEX_AUTH_ENV_CLEAR_KEYS],
|
||||
};
|
||||
}
|
||||
|
||||
function shellArg(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
async function writeCodexAcpWrapper(params: {
|
||||
wrapperPath: string;
|
||||
codexHome: string;
|
||||
clearEnv: string[];
|
||||
}): Promise<string> {
|
||||
await fs.mkdir(path.dirname(params.wrapperPath), { recursive: true, mode: 0o700 });
|
||||
const content = `#!/usr/bin/env node
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const env = { ...process.env, CODEX_HOME: ${JSON.stringify(params.codexHome)} };
|
||||
for (const key of ${JSON.stringify(params.clearEnv)}) {
|
||||
delete env[key];
|
||||
}
|
||||
|
||||
const child = spawn("npx", ["@zed-industries/codex-acp@^0.11.1"], {
|
||||
stdio: "inherit",
|
||||
env,
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
`;
|
||||
await fs.writeFile(params.wrapperPath, content, { mode: 0o700 });
|
||||
await fs.chmod(params.wrapperPath, 0o700);
|
||||
return shellArg(params.wrapperPath);
|
||||
}
|
||||
|
||||
export async function prepareAcpxCodexAuthConfig(params: {
|
||||
pluginConfig: ResolvedAcpxPluginConfig;
|
||||
stateDir: string;
|
||||
logger?: PluginLogger;
|
||||
}): Promise<ResolvedAcpxPluginConfig> {
|
||||
if (params.pluginConfig.agents[CODEX_AGENT_ID]) {
|
||||
return params.pluginConfig;
|
||||
}
|
||||
|
||||
const agentDir = resolveOpenClawAgentDir();
|
||||
const sourceCodexHome = resolveSourceCodexHome();
|
||||
const bridge =
|
||||
(await prepareCodexAuthBridge({
|
||||
agentDir,
|
||||
bridgeDir: "acp-auth",
|
||||
profileId: DEFAULT_CODEX_AUTH_PROFILE_ID,
|
||||
sourceCodexHome,
|
||||
})) ??
|
||||
(await prepareCopiedCodexHome({
|
||||
agentDir,
|
||||
sourceCodexHome,
|
||||
}));
|
||||
|
||||
if (!bridge) {
|
||||
params.logger?.debug?.("codex ACP auth bridge skipped: no Codex auth source found");
|
||||
return params.pluginConfig;
|
||||
}
|
||||
|
||||
const wrapperCommand = await writeCodexAcpWrapper({
|
||||
wrapperPath: path.join(params.stateDir, "acpx", "codex-acp-wrapper.mjs"),
|
||||
codexHome: bridge.codexHome,
|
||||
clearEnv: bridge.clearEnv,
|
||||
});
|
||||
|
||||
return {
|
||||
...params.pluginConfig,
|
||||
agents: {
|
||||
...params.pluginConfig.agents,
|
||||
[CODEX_AGENT_ID]: wrapperCommand,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
const { runtimeRegistry } = vi.hoisted(() => ({
|
||||
runtimeRegistry: new Map<string, { runtime: unknown; healthy?: () => boolean }>(),
|
||||
}));
|
||||
const { prepareAcpxCodexAuthConfigMock } = vi.hoisted(() => ({
|
||||
prepareAcpxCodexAuthConfigMock: vi.fn(
|
||||
async ({ pluginConfig }: { pluginConfig: unknown }) => pluginConfig,
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../runtime-api.js", () => ({
|
||||
getAcpRuntimeBackend: (id: string) => runtimeRegistry.get(id),
|
||||
@@ -24,6 +29,10 @@ vi.mock("./runtime.js", () => ({
|
||||
createFileSessionStore: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("./codex-auth-bridge.js", () => ({
|
||||
prepareAcpxCodexAuthConfig: prepareAcpxCodexAuthConfigMock,
|
||||
}));
|
||||
|
||||
import { getAcpRuntimeBackend } from "../runtime-api.js";
|
||||
import { createAcpxRuntimeService } from "./service.js";
|
||||
|
||||
@@ -37,6 +46,7 @@ async function makeTempDir(): Promise<string> {
|
||||
|
||||
afterEach(async () => {
|
||||
runtimeRegistry.clear();
|
||||
prepareAcpxCodexAuthConfigMock.mockClear();
|
||||
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME;
|
||||
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE;
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
PluginLogger,
|
||||
} from "../runtime-api.js";
|
||||
import { registerAcpRuntimeBackend, unregisterAcpRuntimeBackend } from "../runtime-api.js";
|
||||
import { prepareAcpxCodexAuthConfig } from "./codex-auth-bridge.js";
|
||||
import {
|
||||
resolveAcpxPluginConfig,
|
||||
toAcpMcpServers,
|
||||
@@ -97,10 +98,15 @@ export function createAcpxRuntimeService(
|
||||
return;
|
||||
}
|
||||
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
const basePluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: params.pluginConfig,
|
||||
workspaceDir: ctx.workspaceDir,
|
||||
});
|
||||
const pluginConfig = await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig: basePluginConfig,
|
||||
stateDir: ctx.stateDir,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
await fs.mkdir(pluginConfig.stateDir, { recursive: true });
|
||||
warnOnIgnoredLegacyCompatibilityConfig({
|
||||
pluginConfig,
|
||||
|
||||
@@ -874,6 +874,9 @@ export class GatewayClient {
|
||||
if (!this.lastTick) {
|
||||
return;
|
||||
}
|
||||
if (this.pending.size > 0) {
|
||||
return;
|
||||
}
|
||||
const gap = Date.now() - this.lastTick;
|
||||
if (gap > this.tickIntervalMs * 2) {
|
||||
this.ws?.close(4000, "tick timeout");
|
||||
|
||||
@@ -145,6 +145,52 @@ describe("GatewayClient", () => {
|
||||
}
|
||||
}, 4000);
|
||||
|
||||
test("lets pending requests own their timeout when ticks are missing", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const client = new GatewayClient({
|
||||
requestTimeoutMs: 10_000,
|
||||
tickWatchMinIntervalMs: 5,
|
||||
});
|
||||
const close = vi.fn();
|
||||
const pending = (client as unknown as { pending: Map<string, unknown> }).pending;
|
||||
Object.assign(
|
||||
client as unknown as { ws: unknown; tickIntervalMs: number; lastTick: number },
|
||||
{
|
||||
ws: {
|
||||
readyState: WebSocket.OPEN,
|
||||
send: vi.fn(),
|
||||
close,
|
||||
},
|
||||
tickIntervalMs: 5,
|
||||
lastTick: Date.now(),
|
||||
},
|
||||
);
|
||||
pending.set("long-rpc", {
|
||||
resolve: vi.fn(),
|
||||
reject: vi.fn(),
|
||||
expectFinal: false,
|
||||
timeout: null,
|
||||
});
|
||||
|
||||
(
|
||||
client as unknown as {
|
||||
startTickWatch: () => void;
|
||||
}
|
||||
).startTickWatch();
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
|
||||
pending.clear();
|
||||
await vi.advanceTimersByTimeAsync(5);
|
||||
|
||||
expect(close).toHaveBeenCalledWith(4000, "tick timeout");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("times out unresolved requests and clears pending state", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { saveAuthProfileStore } from "./agent-runtime.js";
|
||||
import * as providerAuthRuntime from "./provider-auth-runtime.js";
|
||||
|
||||
describe("plugin-sdk provider-auth-runtime", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function makeTempDir(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-provider-auth-runtime-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
it("exports the runtime-ready auth helper", () => {
|
||||
expect(typeof providerAuthRuntime.getRuntimeAuthForModel).toBe("function");
|
||||
});
|
||||
@@ -15,4 +33,85 @@ describe("plugin-sdk provider-auth-runtime", () => {
|
||||
expect(typeof providerAuthRuntime.parseOAuthCallbackInput).toBe("function");
|
||||
expect(typeof providerAuthRuntime.waitForLocalOAuthCallback).toBe("function");
|
||||
});
|
||||
|
||||
it("does not write incomplete Codex ChatGPT auth without an id token", async () => {
|
||||
const agentDir = await makeTempDir();
|
||||
saveAuthProfileStore(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai-codex:default": {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
agentDir,
|
||||
{ filterExternalAuthProfiles: false },
|
||||
);
|
||||
|
||||
const bridge = await providerAuthRuntime.prepareCodexAuthBridge({
|
||||
agentDir,
|
||||
bridgeDir: "harness-auth",
|
||||
profileId: "openai-codex:default",
|
||||
});
|
||||
|
||||
expect(bridge).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hydrates missing Codex id token from a matching source auth file", async () => {
|
||||
const root = await makeTempDir();
|
||||
const agentDir = path.join(root, "agent");
|
||||
const sourceCodexHome = path.join(root, "codex-home");
|
||||
await fs.mkdir(sourceCodexHome, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(sourceCodexHome, "auth.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
auth_mode: "chatgpt",
|
||||
tokens: {
|
||||
id_token: "source-id-token",
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
account_id: "acct-123",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
saveAuthProfileStore(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai-codex:default": {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
accountId: "acct-123",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
agentDir,
|
||||
{ filterExternalAuthProfiles: false },
|
||||
);
|
||||
|
||||
const bridge = await providerAuthRuntime.prepareCodexAuthBridge({
|
||||
agentDir,
|
||||
bridgeDir: "harness-auth",
|
||||
profileId: "openai-codex:default",
|
||||
sourceCodexHome,
|
||||
});
|
||||
|
||||
expect(bridge?.codexHome).toContain(path.join(agentDir, "harness-auth", "codex"));
|
||||
const authFile = JSON.parse(
|
||||
await fs.readFile(path.join(bridge?.codexHome ?? "", "auth.json"), "utf8"),
|
||||
);
|
||||
expect(authFile.tokens.id_token).toBe("source-id-token");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -227,6 +227,23 @@ export function resolveCodexAuthBridgeHome(params: {
|
||||
return path.join(params.agentDir, params.bridgeDir, "codex", digest);
|
||||
}
|
||||
|
||||
function assertExistingCodexAuthBridgeFileSafe(codexHome: string): void {
|
||||
const authFile = path.join(codexHome, "auth.json");
|
||||
try {
|
||||
const stat = fs.lstatSync(authFile);
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new Error(`Private secret file ${authFile} must not be a symlink.`);
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`Private secret file ${authFile} must be a regular file.`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCodexAuthBridgeFile(
|
||||
credential: OAuthCredential,
|
||||
material: Partial<CodexAuthBridgeMaterial> = {},
|
||||
@@ -268,11 +285,15 @@ export async function prepareCodexAuthBridge(params: {
|
||||
}
|
||||
|
||||
const codexHome = resolveCodexAuthBridgeHome(params);
|
||||
assertExistingCodexAuthBridgeFileSafe(codexHome);
|
||||
const material = resolveCodexAuthBridgeMaterial({
|
||||
credential,
|
||||
sourceCodexHome: params.sourceCodexHome,
|
||||
env: { ...process.env, ...params.env },
|
||||
});
|
||||
if (!readCodexAuthString(credential.idToken) && !readCodexAuthString(material.idToken)) {
|
||||
return undefined;
|
||||
}
|
||||
await writePrivateSecretFileAtomic({
|
||||
rootDir: params.agentDir,
|
||||
filePath: path.join(codexHome, "auth.json"),
|
||||
|
||||
@@ -31,6 +31,10 @@ vi.mock("@mariozechner/pi-ai/oauth", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../extensions/openai/openai-codex-provider.runtime.js", () => ({
|
||||
refreshOpenAICodexToken: refreshOpenAICodexTokenMock,
|
||||
}));
|
||||
|
||||
function createModel(overrides: Partial<ProviderRuntimeModel> & Pick<ProviderRuntimeModel, "id">) {
|
||||
return {
|
||||
id: overrides.id,
|
||||
|
||||
Reference in New Issue
Block a user