mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 22:09:48 +08:00
e98fa33bc2
Implements the preferred_image_backend: codex-imagegen config key
already referenced in several SKILL.md files but with no corresponding
backend. Bridges non-Codex runtimes (Claude Code, etc.) to Codex CLI's
built-in image_gen tool via 'codex exec'. Uses the user's Codex
subscription — no OPENAI_API_KEY required.
Reliability:
- Retry with exponential backoff (default 2 retries, configurable)
- 9 classified error kinds (retryable vs non-retryable)
- Node child_process timeout with SIGTERM→SIGKILL ladder
Correctness:
- Verifies image_gen was actually invoked (not bypassed) by checking
$CODEX_HOME/generated_images/{thread_id}/ for a PNG
- PNG magic byte validation
- Output file size sanity check
Observability:
- Structured JSONL logging with --log-file
- Verbose stderr mode with --verbose
- Returns thread_id, tool_calls, token usage in result JSON
- Raw codex event stream preserved per attempt for debugging
Efficiency:
- Idempotency cache via --cache-dir (sha256 of prompt+aspect+refs)
- Cache hit returns in <0.3s vs 50-90s cold
Features:
- --ref reference images (repeatable, passed through to codex exec --image)
- --retries, --retry-delay, --timeout all configurable
- File lock serializes concurrent invocations
Testing:
- 16 Bun unit tests across parser, cache, validator
- Path-filtered CI workflow at .github/workflows/codex-imagegen-tests.yml
- Composes with existing test.yml without conflicts
Architecture:
- scripts/codex-imagegen.sh: thin bash entrypoint (bun → npx -y bun fallback)
- scripts/codex-imagegen/: TypeScript module
- main.ts (orchestrator), types.ts (CliOptions/GenerateResult/GenError),
spawn.ts (child_process), parser.ts (JSONL events), validator.ts
(image_gen check + PNG magic), cache.ts (sha256 cache + FileLock),
logger.ts (JSONL structured logger)
Trade-offs documented in docs/codex-imagegen-backend.md:
- 5-10x slower than direct OpenAI API (or near-free on cache hit)
- ToS gray area: image_gen is documented for interactive use; non-
interactive invocation via codex exec is not explicitly addressed.
Suitable for personal low-volume use; users are responsible for
ensuring their usage complies with applicable terms of service.
82 lines
2.5 KiB
TypeScript
82 lines
2.5 KiB
TypeScript
import type { CodexRunResult, ToolCall, TokenUsage } from "./types.ts";
|
|
|
|
export function parseEventStream(raw: string): Omit<CodexRunResult, "rawLogPath" | "durationMs"> {
|
|
const lines = raw.split("\n").filter((l) => l.trim().length > 0);
|
|
let threadId: string | null = null;
|
|
let agentMessage: string | null = null;
|
|
let usage: TokenUsage | null = null;
|
|
const toolCallsById = new Map<string, ToolCall>();
|
|
|
|
for (const line of lines) {
|
|
let event: any;
|
|
try {
|
|
event = JSON.parse(line);
|
|
} catch {
|
|
continue;
|
|
}
|
|
const type = event?.type;
|
|
if (type === "thread.started") {
|
|
threadId = event.thread_id ?? null;
|
|
} else if (type === "item.started" || type === "item.completed") {
|
|
const item = event.item;
|
|
if (!item?.id) continue;
|
|
const tc: ToolCall = {
|
|
id: item.id,
|
|
tool: deriveToolName(item),
|
|
status: item.status ?? (type === "item.completed" ? "completed" : "in_progress"),
|
|
command: item.command,
|
|
};
|
|
toolCallsById.set(item.id, tc);
|
|
if (item.type === "agent_message" && type === "item.completed") {
|
|
agentMessage = String(item.text ?? "");
|
|
}
|
|
} else if (type === "turn.completed") {
|
|
const u = event.usage;
|
|
if (u) {
|
|
usage = {
|
|
input: u.input_tokens ?? 0,
|
|
cached_input: u.cached_input_tokens ?? 0,
|
|
output: u.output_tokens ?? 0,
|
|
reasoning: u.reasoning_output_tokens ?? 0,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
threadId,
|
|
toolCalls: Array.from(toolCallsById.values()),
|
|
agentMessage,
|
|
usage,
|
|
};
|
|
}
|
|
|
|
function deriveToolName(item: any): string {
|
|
if (item.type === "command_execution") return "shell";
|
|
if (item.type === "agent_message") return "agent_message";
|
|
if (item.type === "image_gen" || item.type === "image_generation") return "image_gen";
|
|
if (typeof item.tool === "string") return item.tool;
|
|
return item.type ?? "unknown";
|
|
}
|
|
|
|
export function hasImageGenInvocation(toolCalls: ToolCall[]): boolean {
|
|
return toolCalls.some((tc) => tc.tool === "image_gen");
|
|
}
|
|
|
|
export function parseFinalJson(agentMessage: string | null): { path?: string; bytes?: number } | null {
|
|
if (!agentMessage) return null;
|
|
const trimmed = agentMessage.trim();
|
|
const candidates = [trimmed];
|
|
const match = trimmed.match(/\{[^{}]*"status"\s*:\s*"ok"[^{}]*\}/);
|
|
if (match) candidates.push(match[0]);
|
|
for (const c of candidates) {
|
|
try {
|
|
const obj = JSON.parse(c);
|
|
if (obj?.status === "ok") return { path: obj.path, bytes: obj.bytes };
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
return null;
|
|
}
|