feat: adapter model discovery, reasoning effort, and improved codex formatting
Add dynamic OpenAI model list fetching for codex adapter with caching, async listModels interface, reasoning effort support for both claude and codex adapters, optional timeouts (default to unlimited), wakeCommentId context propagation, and richer codex stdout event parsing/formatting. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
58
server/src/__tests__/adapter-models.test.ts
Normal file
58
server/src/__tests__/adapter-models.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { models as codexFallbackModels } from "@paperclip/adapter-codex-local";
|
||||
import { listAdapterModels } from "../adapters/index.js";
|
||||
import { resetCodexModelsCacheForTests } from "../adapters/codex-models.js";
|
||||
|
||||
describe("adapter model listing", () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
resetCodexModelsCacheForTests();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns an empty list for unknown adapters", async () => {
|
||||
const models = await listAdapterModels("unknown_adapter");
|
||||
expect(models).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns codex fallback models when no OpenAI key is available", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
const models = await listAdapterModels("codex_local");
|
||||
|
||||
expect(models).toEqual(codexFallbackModels);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads codex models dynamically and merges fallback options", async () => {
|
||||
process.env.OPENAI_API_KEY = "sk-test";
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{ id: "gpt-5-pro" },
|
||||
{ id: "gpt-5" },
|
||||
],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
const first = await listAdapterModels("codex_local");
|
||||
const second = await listAdapterModels("codex_local");
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(first).toEqual(second);
|
||||
expect(first.some((model) => model.id === "gpt-5-pro")).toBe(true);
|
||||
expect(first.some((model) => model.id === "codex-mini-latest")).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to static codex models when OpenAI model discovery fails", async () => {
|
||||
process.env.OPENAI_API_KEY = "sk-test";
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({}),
|
||||
} as Response);
|
||||
|
||||
const models = await listAdapterModels("codex_local");
|
||||
expect(models).toEqual(codexFallbackModels);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { isCodexUnknownSessionError, parseCodexJsonl } from "@paperclip/adapter-codex-local/server";
|
||||
import { parseCodexStdoutLine } from "@paperclip/adapter-codex-local/ui";
|
||||
import { printCodexStreamEvent } from "@paperclip/adapter-codex-local/cli";
|
||||
|
||||
describe("codex_local parser", () => {
|
||||
it("extracts session, summary, usage, and terminal error message", () => {
|
||||
@@ -30,3 +32,220 @@ describe("codex_local stale session detection", () => {
|
||||
expect(isCodexUnknownSessionError("", stderr)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("codex_local ui stdout parser", () => {
|
||||
it("parses turn and reasoning lifecycle events", () => {
|
||||
const ts = "2026-02-20T00:00:00.000Z";
|
||||
|
||||
expect(parseCodexStdoutLine(JSON.stringify({ type: "turn.started" }), ts)).toEqual([
|
||||
{ kind: "system", ts, text: "turn started" },
|
||||
]);
|
||||
|
||||
expect(
|
||||
parseCodexStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "item.completed",
|
||||
item: { id: "item_1", type: "reasoning", text: "**Preparing to use paperclip skill**" },
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{ kind: "thinking", ts, text: "**Preparing to use paperclip skill**" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses command execution and file changes", () => {
|
||||
const ts = "2026-02-20T00:00:00.000Z";
|
||||
|
||||
expect(
|
||||
parseCodexStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "item.started",
|
||||
item: { id: "item_2", type: "command_execution", command: "/bin/zsh -lc ls", status: "in_progress" },
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: "command_execution",
|
||||
input: { id: "item_2", command: "/bin/zsh -lc ls" },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
parseCodexStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "item.completed",
|
||||
item: {
|
||||
id: "item_2",
|
||||
type: "command_execution",
|
||||
command: "/bin/zsh -lc ls",
|
||||
aggregated_output: "agents\n",
|
||||
exit_code: 0,
|
||||
status: "completed",
|
||||
},
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId: "item_2",
|
||||
content: "command: /bin/zsh -lc ls\nstatus: completed\nexit_code: 0\n\nagents",
|
||||
isError: false,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
parseCodexStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "item.completed",
|
||||
item: {
|
||||
id: "item_52",
|
||||
type: "file_change",
|
||||
changes: [{ path: "/Users/genericuser/paperclip/ui/src/pages/AgentDetail.tsx", kind: "update" }],
|
||||
status: "completed",
|
||||
},
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "system",
|
||||
ts,
|
||||
text: "file changes: update /Users/genericuser/paperclip/ui/src/pages/AgentDetail.tsx",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses error items and failed turns", () => {
|
||||
const ts = "2026-02-20T00:00:00.000Z";
|
||||
|
||||
expect(
|
||||
parseCodexStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "item.completed",
|
||||
item: {
|
||||
id: "item_0",
|
||||
type: "error",
|
||||
message: "This session was recorded with model `gpt-5.2-pro` but is resuming with `gpt-5.2-codex`.",
|
||||
},
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "stderr",
|
||||
ts,
|
||||
text: "This session was recorded with model `gpt-5.2-pro` but is resuming with `gpt-5.2-codex`.",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
parseCodexStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "turn.failed",
|
||||
error: { message: "model access denied" },
|
||||
usage: { input_tokens: 10, cached_input_tokens: 2, output_tokens: 4 },
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: "",
|
||||
inputTokens: 10,
|
||||
outputTokens: 4,
|
||||
cachedTokens: 2,
|
||||
costUsd: 0,
|
||||
subtype: "turn.failed",
|
||||
isError: true,
|
||||
errors: ["model access denied"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return value.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
}
|
||||
|
||||
describe("codex_local cli formatter", () => {
|
||||
it("prints lifecycle, command execution, file change, and error events", () => {
|
||||
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
printCodexStreamEvent(JSON.stringify({ type: "turn.started" }), false);
|
||||
printCodexStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "item.started",
|
||||
item: { id: "item_2", type: "command_execution", command: "/bin/zsh -lc ls", status: "in_progress" },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
printCodexStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "item.completed",
|
||||
item: {
|
||||
id: "item_2",
|
||||
type: "command_execution",
|
||||
command: "/bin/zsh -lc ls",
|
||||
aggregated_output: "agents\n",
|
||||
exit_code: 0,
|
||||
status: "completed",
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
printCodexStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "item.completed",
|
||||
item: {
|
||||
id: "item_52",
|
||||
type: "file_change",
|
||||
changes: [{ path: "/Users/genericuser/paperclip/ui/src/pages/AgentDetail.tsx", kind: "update" }],
|
||||
status: "completed",
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
printCodexStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "turn.failed",
|
||||
error: { message: "model access denied" },
|
||||
usage: { input_tokens: 10, cached_input_tokens: 2, output_tokens: 4 },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
printCodexStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "item.completed",
|
||||
item: { type: "error", message: "resume model mismatch" },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
const lines = spy.mock.calls
|
||||
.map((call) => call.map((v) => String(v)).join(" "))
|
||||
.map(stripAnsi);
|
||||
|
||||
expect(lines).toEqual(expect.arrayContaining([
|
||||
"turn started",
|
||||
"tool_call: command_execution",
|
||||
"/bin/zsh -lc ls",
|
||||
"tool_result: command_execution command=\"/bin/zsh -lc ls\" status=completed exit_code=0",
|
||||
"agents",
|
||||
"file_change: update /Users/genericuser/paperclip/ui/src/pages/AgentDetail.tsx",
|
||||
"turn failed: model access denied",
|
||||
"tokens: in=10 out=4 cached=2",
|
||||
"error: resume model mismatch",
|
||||
]));
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
104
server/src/adapters/codex-models.ts
Normal file
104
server/src/adapters/codex-models.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { AdapterModel } from "./types.js";
|
||||
import { models as codexFallbackModels } from "@paperclip/adapter-codex-local";
|
||||
import { readConfigFile } from "../config-file.js";
|
||||
|
||||
const OPENAI_MODELS_ENDPOINT = "https://api.openai.com/v1/models";
|
||||
const OPENAI_MODELS_TIMEOUT_MS = 5000;
|
||||
const OPENAI_MODELS_CACHE_TTL_MS = 60_000;
|
||||
|
||||
let cached: { keyFingerprint: string; expiresAt: number; models: AdapterModel[] } | null = null;
|
||||
|
||||
function fingerprint(apiKey: string): string {
|
||||
return `${apiKey.length}:${apiKey.slice(-6)}`;
|
||||
}
|
||||
|
||||
function dedupeModels(models: AdapterModel[]): AdapterModel[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: AdapterModel[] = [];
|
||||
for (const model of models) {
|
||||
const id = model.id.trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
deduped.push({ id, label: model.label.trim() || id });
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function mergedWithFallback(models: AdapterModel[]): AdapterModel[] {
|
||||
return dedupeModels([
|
||||
...models,
|
||||
...codexFallbackModels,
|
||||
]).sort((a, b) => a.id.localeCompare(b.id, "en", { numeric: true, sensitivity: "base" }));
|
||||
}
|
||||
|
||||
function resolveOpenAiApiKey(): string | null {
|
||||
const envKey = process.env.OPENAI_API_KEY?.trim();
|
||||
if (envKey) return envKey;
|
||||
|
||||
const config = readConfigFile();
|
||||
if (config?.llm?.provider !== "openai") return null;
|
||||
const configKey = config.llm.apiKey?.trim();
|
||||
return configKey && configKey.length > 0 ? configKey : null;
|
||||
}
|
||||
|
||||
async function fetchOpenAiModels(apiKey: string): Promise<AdapterModel[]> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), OPENAI_MODELS_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(OPENAI_MODELS_ENDPOINT, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
|
||||
const payload = (await response.json()) as { data?: unknown };
|
||||
const data = Array.isArray(payload.data) ? payload.data : [];
|
||||
const models: AdapterModel[] = [];
|
||||
for (const item of data) {
|
||||
if (typeof item !== "object" || item === null) continue;
|
||||
const id = (item as { id?: unknown }).id;
|
||||
if (typeof id !== "string" || id.trim().length === 0) continue;
|
||||
models.push({ id, label: id });
|
||||
}
|
||||
return dedupeModels(models);
|
||||
} catch {
|
||||
return [];
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listCodexModels(): Promise<AdapterModel[]> {
|
||||
const apiKey = resolveOpenAiApiKey();
|
||||
const fallback = dedupeModels(codexFallbackModels);
|
||||
if (!apiKey) return fallback;
|
||||
|
||||
const now = Date.now();
|
||||
const keyFingerprint = fingerprint(apiKey);
|
||||
if (cached && cached.keyFingerprint === keyFingerprint && cached.expiresAt > now) {
|
||||
return cached.models;
|
||||
}
|
||||
|
||||
const fetched = await fetchOpenAiModels(apiKey);
|
||||
if (fetched.length > 0) {
|
||||
const merged = mergedWithFallback(fetched);
|
||||
cached = {
|
||||
keyFingerprint,
|
||||
expiresAt: now + OPENAI_MODELS_CACHE_TTL_MS,
|
||||
models: merged,
|
||||
};
|
||||
return merged;
|
||||
}
|
||||
|
||||
if (cached && cached.keyFingerprint === keyFingerprint && cached.models.length > 0) {
|
||||
return cached.models;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function resetCodexModelsCacheForTests() {
|
||||
cached = null;
|
||||
}
|
||||
@@ -7,13 +7,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (!url) throw new Error("HTTP adapter missing url");
|
||||
|
||||
const method = asString(config.method, "POST");
|
||||
const timeoutMs = asNumber(config.timeoutMs, 15000);
|
||||
const timeoutMs = asNumber(config.timeoutMs, 0);
|
||||
const headers = parseObject(config.headers) as Record<string, string>;
|
||||
const payloadTemplate = parseObject(config.payloadTemplate);
|
||||
const body = { ...payloadTemplate, agentId: agent.id, runId, context };
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
@@ -23,7 +23,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
...(timer ? { signal: controller.signal } : {}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -37,6 +37,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
summary: `HTTP ${method} ${url}`,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (typeof v === "string") env[k] = v;
|
||||
}
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 900);
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 15);
|
||||
|
||||
if (onMeta) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { execute as claudeExecute, sessionCodec as claudeSessionCodec } from "@p
|
||||
import { agentConfigurationDoc as claudeAgentConfigurationDoc, models as claudeModels } from "@paperclip/adapter-claude-local";
|
||||
import { execute as codexExecute, sessionCodec as codexSessionCodec } from "@paperclip/adapter-codex-local/server";
|
||||
import { agentConfigurationDoc as codexAgentConfigurationDoc, models as codexModels } from "@paperclip/adapter-codex-local";
|
||||
import { listCodexModels } from "./codex-models.js";
|
||||
import { processAdapter } from "./process/index.js";
|
||||
import { httpAdapter } from "./http/index.js";
|
||||
|
||||
@@ -20,6 +21,7 @@ const codexLocalAdapter: ServerAdapterModule = {
|
||||
execute: codexExecute,
|
||||
sessionCodec: codexSessionCodec,
|
||||
models: codexModels,
|
||||
listModels: listCodexModels,
|
||||
supportsLocalAgentJwt: true,
|
||||
agentConfigurationDoc: codexAgentConfigurationDoc,
|
||||
};
|
||||
@@ -37,8 +39,14 @@ export function getServerAdapter(type: string): ServerAdapterModule {
|
||||
return adapter;
|
||||
}
|
||||
|
||||
export function listAdapterModels(type: string): { id: string; label: string }[] {
|
||||
return adaptersByType.get(type)?.models ?? [];
|
||||
export async function listAdapterModels(type: string): Promise<{ id: string; label: string }[]> {
|
||||
const adapter = adaptersByType.get(type);
|
||||
if (!adapter) return [];
|
||||
if (adapter.listModels) {
|
||||
const discovered = await adapter.listModels();
|
||||
if (discovered.length > 0) return discovered;
|
||||
}
|
||||
return adapter.models ?? [];
|
||||
}
|
||||
|
||||
export function listServerAdapters(): ServerAdapterModule[] {
|
||||
|
||||
@@ -9,5 +9,6 @@ export type {
|
||||
AdapterInvocationMeta,
|
||||
AdapterExecutionContext,
|
||||
AdapterSessionCodec,
|
||||
AdapterModel,
|
||||
ServerAdapterModule,
|
||||
} from "@paperclip/adapter-utils";
|
||||
|
||||
@@ -189,9 +189,9 @@ export function agentRoutes(db: Db) {
|
||||
};
|
||||
}
|
||||
|
||||
router.get("/adapters/:type/models", (req, res) => {
|
||||
router.get("/adapters/:type/models", async (req, res) => {
|
||||
const type = req.params.type as string;
|
||||
const models = listAdapterModels(type);
|
||||
const models = await listAdapterModels(type);
|
||||
res.json(models);
|
||||
});
|
||||
|
||||
@@ -890,6 +890,36 @@ export function agentRoutes(db: Db) {
|
||||
res.json(runs);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/live-runs", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
const liveRuns = await db
|
||||
.select({
|
||||
id: heartbeatRuns.id,
|
||||
status: heartbeatRuns.status,
|
||||
invocationSource: heartbeatRuns.invocationSource,
|
||||
triggerDetail: heartbeatRuns.triggerDetail,
|
||||
startedAt: heartbeatRuns.startedAt,
|
||||
finishedAt: heartbeatRuns.finishedAt,
|
||||
createdAt: heartbeatRuns.createdAt,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
agentName: agentsTable.name,
|
||||
adapterType: agentsTable.adapterType,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.innerJoin(agentsTable, eq(heartbeatRuns.agentId, agentsTable.id))
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, companyId),
|
||||
inArray(heartbeatRuns.status, ["queued", "running"]),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(heartbeatRuns.createdAt));
|
||||
|
||||
res.json(liveRuns);
|
||||
});
|
||||
|
||||
router.post("/heartbeat-runs/:runId/cancel", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const runId = req.params.runId as string;
|
||||
|
||||
Reference in New Issue
Block a user