feat: discover codex app-server capabilities
This commit is contained in:
@@ -58,6 +58,25 @@ function normalizeTimeoutMs(value) {
|
||||
return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : 120_000;
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value, fallback) {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : fallback;
|
||||
}
|
||||
|
||||
function boolFromConfigOrEnv(configValue, envValue, fallback) {
|
||||
if (configValue === true || configValue === false) {
|
||||
return configValue;
|
||||
}
|
||||
const normalized = trimToDefined(envValue)?.toLowerCase();
|
||||
if (normalized === "1" || normalized === "true" || normalized === "yes") {
|
||||
return true;
|
||||
}
|
||||
if (normalized === "0" || normalized === "false" || normalized === "no") {
|
||||
return false;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeTransport(value) {
|
||||
const normalized = trimToDefined(value)?.toLowerCase();
|
||||
return normalized === "ws" || normalized === "websocket"
|
||||
@@ -117,6 +136,19 @@ export function getCodexAppServerRunnerConfig(env = process.env, config = {}) {
|
||||
authTokenFile:
|
||||
trimToDefined(config.codexAppServerAuthTokenFile) ||
|
||||
trimToDefined(env.BOSS_CODEX_APP_SERVER_AUTH_TOKEN_FILE),
|
||||
discoveryEnabled: boolFromConfigOrEnv(
|
||||
config.codexAppServerDiscoveryEnabled,
|
||||
env.BOSS_CODEX_APP_SERVER_DISCOVERY_ENABLED,
|
||||
true,
|
||||
),
|
||||
discoveryTtlMs: normalizePositiveInteger(
|
||||
config.codexAppServerDiscoveryTtlMs ?? env.BOSS_CODEX_APP_SERVER_DISCOVERY_TTL_MS,
|
||||
300_000,
|
||||
),
|
||||
discoveryLimit: normalizePositiveInteger(
|
||||
config.codexAppServerDiscoveryLimit ?? env.BOSS_CODEX_APP_SERVER_DISCOVERY_LIMIT,
|
||||
20,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -727,6 +759,226 @@ function buildServerRequestFallbackResponse(message) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDiscoveryModel(model) {
|
||||
const id = trimToDefined(model?.id) || trimToDefined(model?.model);
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
model: trimToDefined(model?.model) || id,
|
||||
displayName: trimToDefined(model?.displayName) || id,
|
||||
description: safeProgressText(model?.description, 160),
|
||||
hidden: Boolean(model?.hidden),
|
||||
isDefault: Boolean(model?.isDefault),
|
||||
supportsPersonality: Boolean(model?.supportsPersonality),
|
||||
supportedReasoningEfforts: asArray(model?.supportedReasoningEfforts).map(String).slice(0, 8),
|
||||
inputModalities: asArray(model?.inputModalities).map(String).slice(0, 8),
|
||||
};
|
||||
}
|
||||
|
||||
function pickFastModelId(models) {
|
||||
const fast = models.find((model) =>
|
||||
/mini|fast|flash|lite|haiku/i.test(`${model.id} ${model.displayName} ${model.description}`),
|
||||
);
|
||||
return fast?.id || models[0]?.id || "";
|
||||
}
|
||||
|
||||
function normalizeDiscoverySkills(result) {
|
||||
return asArray(result?.data)
|
||||
.flatMap((entry) => asArray(entry?.skills))
|
||||
.map((skill) => {
|
||||
const name = trimToDefined(skill?.name);
|
||||
if (!name) return null;
|
||||
return {
|
||||
name,
|
||||
description: safeProgressText(skill?.description || skill?.shortDescription, 180),
|
||||
scope: trimToDefined(skill?.scope),
|
||||
enabled: skill?.enabled !== false,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeDiscoveryPlugins(result) {
|
||||
return asArray(result?.marketplaces)
|
||||
.flatMap((marketplace) => asArray(marketplace?.plugins))
|
||||
.map((plugin) => {
|
||||
const id = trimToDefined(plugin?.id) || trimToDefined(plugin?.name);
|
||||
if (!id) return null;
|
||||
return {
|
||||
id,
|
||||
name: trimToDefined(plugin?.name) || id,
|
||||
installed: Boolean(plugin?.installed),
|
||||
enabled: plugin?.enabled !== false,
|
||||
localVersion: trimToDefined(plugin?.localVersion),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeDiscoveryApps(result) {
|
||||
return asArray(result?.data)
|
||||
.map((app) => {
|
||||
const id = trimToDefined(app?.id) || trimToDefined(app?.name);
|
||||
if (!id) return null;
|
||||
return {
|
||||
id,
|
||||
name: trimToDefined(app?.name) || id,
|
||||
description: safeProgressText(app?.description, 160),
|
||||
isAccessible: app?.isAccessible !== false,
|
||||
isEnabled: app?.isEnabled !== false,
|
||||
pluginDisplayNames: asArray(app?.pluginDisplayNames).map(String).slice(0, 8),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function withCodexAppServerRpcSession(runnerConfig, callback) {
|
||||
const cwd = runnerConfig.cwd || process.cwd();
|
||||
let closed = false;
|
||||
let rpcTransport;
|
||||
let nextId = 1;
|
||||
const pending = new Map();
|
||||
const timeout = setTimeout(() => {
|
||||
for (const { reject } of pending.values()) {
|
||||
reject(new Error("CODEX_APP_SERVER_DISCOVERY_TIMEOUT"));
|
||||
}
|
||||
pending.clear();
|
||||
rpcTransport?.close?.("SIGKILL");
|
||||
}, Math.min(runnerConfig.timeoutMs, 10_000));
|
||||
|
||||
const request = (method, params = {}) =>
|
||||
new Promise((resolveRequest, rejectRequest) => {
|
||||
if (closed) {
|
||||
rejectRequest(new Error("CODEX_APP_SERVER_CLOSED"));
|
||||
return;
|
||||
}
|
||||
const id = nextId++;
|
||||
pending.set(id, { resolve: resolveRequest, reject: rejectRequest });
|
||||
rpcTransport.send(JSON.stringify({ method, id, params }), (error) => {
|
||||
if (error) {
|
||||
pending.delete(id);
|
||||
rejectRequest(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const notify = (method, params = {}) => {
|
||||
if (!closed) {
|
||||
rpcTransport.send(JSON.stringify({ method, params }));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
rpcTransport = await openCodexAppServerTransport(runnerConfig, cwd, {
|
||||
onLine(line) {
|
||||
if (!line.trim()) return;
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(line);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!Object.hasOwn(message, "id")) return;
|
||||
const pendingRequest = pending.get(message.id);
|
||||
if (!pendingRequest) return;
|
||||
pending.delete(message.id);
|
||||
if (message.error) {
|
||||
pendingRequest.reject(new Error(message.error.message || JSON.stringify(message.error)));
|
||||
} else {
|
||||
pendingRequest.resolve(message.result ?? {});
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
closed = true;
|
||||
for (const { reject } of pending.values()) {
|
||||
reject(error);
|
||||
}
|
||||
pending.clear();
|
||||
},
|
||||
onClose({ code, message }) {
|
||||
closed = true;
|
||||
const error = new Error(message || `CODEX_APP_SERVER_EXITED:${code ?? "unknown"}`);
|
||||
for (const { reject } of pending.values()) {
|
||||
reject(error);
|
||||
}
|
||||
pending.clear();
|
||||
},
|
||||
});
|
||||
|
||||
await request("initialize", {
|
||||
clientInfo: {
|
||||
name: runnerConfig.clientName,
|
||||
title: runnerConfig.clientTitle,
|
||||
version: runnerConfig.clientVersion,
|
||||
},
|
||||
});
|
||||
notify("initialized", {});
|
||||
return await callback(request);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
rpcTransport?.close?.("SIGTERM");
|
||||
}
|
||||
}
|
||||
|
||||
export async function discoverCodexAppServerCapabilities(runnerConfig) {
|
||||
if (!runnerConfig?.enabled || runnerConfig.discoveryEnabled === false) {
|
||||
return undefined;
|
||||
}
|
||||
const safeRequest = async (request, method, params = {}) => {
|
||||
try {
|
||||
return await request(method, params);
|
||||
} catch (error) {
|
||||
return { __bossError: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
};
|
||||
|
||||
return withCodexAppServerRpcSession(runnerConfig, async (request) => {
|
||||
const limit = runnerConfig.discoveryLimit ?? 20;
|
||||
const [modelResult, providerCapabilities, skillsResult, pluginResult, appsResult] = await Promise.all([
|
||||
safeRequest(request, "model/list", { includeHidden: false, limit }),
|
||||
safeRequest(request, "modelProvider/capabilities/read", {}),
|
||||
safeRequest(request, "skills/list", { cwds: [runnerConfig.cwd || process.cwd()], forceReload: false }),
|
||||
safeRequest(request, "plugin/list", { cwds: [runnerConfig.cwd || process.cwd()] }),
|
||||
safeRequest(request, "app/list", { limit }),
|
||||
]);
|
||||
|
||||
const models = asArray(modelResult?.data)
|
||||
.map(normalizeDiscoveryModel)
|
||||
.filter(Boolean)
|
||||
.slice(0, limit);
|
||||
const defaultModelId = models.find((model) => model.isDefault)?.id || models[0]?.id || "";
|
||||
const fastModelId = pickFastModelId(models);
|
||||
const deepModelId = models.find((model) => model.id !== fastModelId)?.id || defaultModelId;
|
||||
return {
|
||||
version: trimToDefined(runnerConfig.version),
|
||||
discoveredAt: new Date().toISOString(),
|
||||
models,
|
||||
defaultModelId,
|
||||
fastModelId,
|
||||
deepModelId,
|
||||
providerCapabilities: {
|
||||
namespaceTools: Boolean(providerCapabilities?.namespaceTools),
|
||||
imageGeneration: Boolean(providerCapabilities?.imageGeneration),
|
||||
webSearch: Boolean(providerCapabilities?.webSearch),
|
||||
},
|
||||
skills: normalizeDiscoverySkills(skillsResult).slice(0, limit),
|
||||
plugins: normalizeDiscoveryPlugins(pluginResult).slice(0, limit),
|
||||
apps: normalizeDiscoveryApps(appsResult).slice(0, limit),
|
||||
errors: [
|
||||
modelResult?.__bossError ? `model/list:${modelResult.__bossError}` : undefined,
|
||||
providerCapabilities?.__bossError
|
||||
? `modelProvider/capabilities/read:${providerCapabilities.__bossError}`
|
||||
: undefined,
|
||||
skillsResult?.__bossError ? `skills/list:${skillsResult.__bossError}` : undefined,
|
||||
pluginResult?.__bossError ? `plugin/list:${pluginResult.__bossError}` : undefined,
|
||||
appsResult?.__bossError ? `app/list:${appsResult.__bossError}` : undefined,
|
||||
].filter(Boolean),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createProgressCollector() {
|
||||
const steps = [];
|
||||
const artifacts = [];
|
||||
|
||||
Reference in New Issue
Block a user