forked from farhoodlabs/paperclip
8f1cd0474f
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Adapter execution and retry classification decide whether agent work pauses, retries, or recovers automatically > - Transient provider failures need to be classified precisely so Paperclip does not convert retryable upstream conditions into false hard failures > - At the same time, operators need an up-to-date model list for Codex-backed agents and prompts should nudge agents toward targeted verification instead of repo-wide sweeps > - This pull request tightens transient recovery classification for Claude and Codex, updates the agent prompt guidance, and adds Codex model refresh support end-to-end > - The benefit is better automatic retry behavior plus fresher operator-facing model configuration ## What Changed - added Codex usage-limit retry-window parsing and Claude extra-usage transient classification - normalized the heartbeat transient-recovery contract across adapter executions and heartbeat scheduling - documented that deferred comment wakes only reopen completed issues for human/comment-reopen interactions, while system follow-ups leave closed work closed - updated adapter-utils prompt guidance to prefer targeted verification - added Codex model refresh support in the server route, registry, shared types, and agent config form - added adapter/server tests covering the new parsing, retry scheduling, and model-refresh behavior ## Verification - `pnpm exec vitest run --project @paperclipai/adapter-utils packages/adapter-utils/src/server-utils.test.ts` - `pnpm exec vitest run --project @paperclipai/adapter-claude-local packages/adapters/claude-local/src/server/parse.test.ts` - `pnpm exec vitest run --project @paperclipai/adapter-codex-local packages/adapters/codex-local/src/server/parse.test.ts` - `pnpm exec vitest run --project @paperclipai/server server/src/__tests__/adapter-model-refresh-routes.test.ts server/src/__tests__/adapter-models.test.ts server/src/__tests__/claude-local-execute.test.ts server/src/__tests__/codex-local-execute.test.ts server/src/__tests__/heartbeat-process-recovery.test.ts server/src/__tests__/heartbeat-retry-scheduling.test.ts` ## Risks - Moderate behavior risk: retry classification affects whether runs auto-recover or block, so mistakes here could either suppress needed retries or over-retry real failures - Low workflow risk: deferred comment wake reopening is intentionally scoped to human/comment-reopen interactions so system follow-ups do not revive completed issues unexpectedly > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex GPT-5-based coding agent with tool use and code execution in the Codex CLI environment ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
114 lines
3.5 KiB
TypeScript
114 lines
3.5 KiB
TypeScript
import type { AdapterModel } from "./types.js";
|
|
import { models as codexFallbackModels } from "@paperclipai/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);
|
|
}
|
|
}
|
|
|
|
async function loadCodexModels(options?: { forceRefresh?: boolean }): Promise<AdapterModel[]> {
|
|
const forceRefresh = options?.forceRefresh === true;
|
|
const apiKey = resolveOpenAiApiKey();
|
|
const fallback = dedupeModels(codexFallbackModels);
|
|
if (!apiKey) return fallback;
|
|
|
|
const now = Date.now();
|
|
const keyFingerprint = fingerprint(apiKey);
|
|
if (!forceRefresh && 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 async function listCodexModels(): Promise<AdapterModel[]> {
|
|
return loadCodexModels();
|
|
}
|
|
|
|
export async function refreshCodexModels(): Promise<AdapterModel[]> {
|
|
return loadCodexModels({ forceRefresh: true });
|
|
}
|
|
|
|
export function resetCodexModelsCacheForTests() {
|
|
cached = null;
|
|
}
|