mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:56:58 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - New users often connect a Codex agent with an OpenAI API key during
onboarding.
> - A user asked such an agent for a logo. The agent said that the
built-in image generator was not available and that its fallback needed
a separate API key. It then drew an SVG by hand.
> - #19516 removed `--disable image_generation`. That change is correct,
but it does not reach this path. The pinned Codex 0.160.0 shows its
`image_gen` tool only when Codex signs in with ChatGPT. The check is
`image_generation_available` in `codex-rs/core/src/tools/spec_plan.rs`.
An API key never gets the tool.
> - The runner keeps the provider key out of the agent's shell on
purpose, so the skill's `scripts/image_gen.py` fallback cannot run.
> - This pull request adds a Paperclip `generate_image` tool. The server
makes the image with the agent's existing OpenAI key. The key never goes
to the agent process or the shell.
> - The benefit is that API-key Codex agents can make images with no
second key. The spend goes to the agent's budget and the budget can stop
it.
## Linked Issues or Issue Description
Refs #19516
**What happened?**
An agent connected through onboarding with an OpenAI API key (Codex
harness, `gpt-5.6-sol`) could not generate images. The agent said: "The
image skill's built-in generator isn't available in this run, and its
fallback would require a separate API key." It then drew an SVG instead.
**Steps to reproduce**
1. Create a company with a Codex agent that uses an OpenAI API key.
2. Ask the agent for a raster image, for example a logo PNG.
3. The agent reports that image generation is not available. It does not
make the image.
**Expected behavior**
The agent makes the image with the OpenAI key that already powers it.
**Root cause**
The agent ran on the native runner's Codex app-server path, with
`auth.json = {"OPENAI_API_KEY": ...}`. Codex 0.160.0 removes
`image_gen.imagegen` from the model's tools unless
`current_auth_uses_codex_backend()` is true. That function is true only
for a ChatGPT sign-in.
I proved this with the real pinned binary. I gave it the runner's
isolated launch arguments and the API-key auth file, and pointed it at a
local Responses endpoint. The first model request offers only `exec`,
`wait`, `request_user_input` and the collaboration tools. #19516's test
checked only `codex features list`, so it could not see this gate.
## What Changed
- **New `server/src/services/native-runtime/generate-image-tool.ts`:**
- The tool schema and definition.
- `imageGenerationBindingForRun`. It applies only to native Codex runs
on a direct OpenAI `api_key` connection with no router selection.
- The OpenAI Images API call (`gpt-image-2`, `b64_json`, PNG check, 180
s timeout). The reply is read as a stream with a byte cap before it is
parsed. A decoded image larger than 20 MiB is refused.
- A reviewed rate card for `gpt-image-2`: text input $5, image input $8
and image output $30 per 1M tokens, from
developers.openai.com/api/docs/pricing, retrieved 2026-11-06.
- A create-only, symlink-confined local write.
- **`paperclip-runner-tool-authority.ts`:**
- Adds the `imageGeneration` binding.
- Advertises the tool only for standard mode with a workspace. A remote
target must also have an installer. Dot, Muse, Grok and native review do
not get the tool.
- Adds the `#generateImage` handler. Before any spend, it checks again:
the run and task, budget authority, `issue:mutate`, case handoff, and
the connection.
- Stores a claim-before-spend per-call receipt in
`resultJson.imageToolReceipts`. A retry or recovery replays the receipt
and does not pay again.
- Saves a `charged` receipt (usage, price, request ID and charge time)
as soon as OpenAI returns. A failed save, failed accounting write or
crash can then still settle the spend. A retry or recovery records the
cost once and never buys again.
- Records one `cost_events` row through `createCostEventInTransaction`.
The row is `metered_api` with `costStatus: "estimated"`, and the budget
is evaluated. The row is recorded even if saving fails.
- Checks the task's project budget, as well as the company and agent
budgets, before spending.
- Writes content-free `runner.image_generate` activity entries: started,
completed, failed and unconfirmed.
- **`ai-connections.ts`:** adds `selectImageGeneration`. It checks again
that the connection is an active, healthy OpenAI `api_key` connection
with no custom routing. It also checks the responsible user's
membership, the grant audience and the agent or company installation.
These are the same rules that runtime selection uses.
- **`native-session-executor.ts`:** gets the binding from the run's
admitted `contextSnapshot.aiConnection`. The value comes only from the
server, never from tool input.
- **`native-session-resume.ts`:** the native tool contract changes from
`v18` to `v19` and now lists `generate_image`. A persisted provider
thread cannot add tools when it resumes, so older threads must rotate.
- **`doc/runner-api-tools.md`:** a new section about image generation
for OpenAI API-key Codex agents.
- **#19516: kept, not reverted.** Its changes have no effect on the
API-key path. They are still correct for Codex agents that sign in with
ChatGPT, because `image_gen` works there and saves to
`CODEX_HOME/generated_images`. Those agents need the read grant that
#19516 added. On the API-key path, the new tool replaces the built-in
tool. The tool's description tells the agent to use it instead of
`image_gen` or `scripts/image_gen.py`.
## Verification
**Red on `master` (e482f0f632).** Behavioural red with the first version
of the new test:
```
FAIL src/services/native-runtime/generate-image-tool.test.ts > generate_image for OpenAI API-key Codex agents (N-12) > lets an API-key Codex agent make an image with its existing OpenAI key, saved in its workspace and billed
AssertionError: expected [ 'connections_search', …(51) ] to include 'generate_image'
❯ src/services/native-runtime/generate-image-tool.test.ts:78:60
Tests 1 failed | 3 passed (4)
```
The final test file on master's source also fails. The reason is that
the module does not exist: `Cannot find module
'./generate-image-tool.js'`.
**Real pinned Codex binary.** The new test is
`packages/paperclip-runner/src/drivers/codex/codex-api-key-image-tools.test.ts`.
It uses the runner's isolated launch, the API-key `auth.json` and a
local Responses endpoint. It shows that the model gets no `image_gen`,
`imagegen` or `image_generation` tool, and that a server-advertised
`generate_image` tool does reach the model:
```
Test Files 1 passed (1)
Tests 1 passed (1)
```
**Green after the fix:**
`server/src/services/native-runtime/generate-image-tool.test.ts` uses
embedded PostgreSQL and a stubbed OpenAI fetch.
```
Test Files 1 passed (1)
Tests 11 passed (11)
```
The 11 tests cover these behaviours:
- **Main path.** The tool is advertised. The request goes to
`api.openai.com/v1/images/generations` with the existing key. The PNG is
written to the workspace. The key does not appear in the result or in
activity. One `cost_events` row is written: 50 text tokens and 1000
output tokens, which is $0.03025, estimated, from the rate card. A retry
and `recover()` replay the receipt with no second fetch and no second
cost row.
- **No binding:** the tool is not offered and `execute` refuses.
- **Budget hard-stop.** The stop pauses the agent, so the call is
refused before any fetch and no file is written.
- **Bad grant:** the call is refused before any fetch.
- **Project budget stop.** The task's project is paused by its budget,
so the call is refused before any fetch.
- **Charge settlement.** A crash left a `charged` receipt. The retry and
`recover()` record the cost once, report the image path and do not buy
another image.
- **Oversized reply.** The read stops before the body is parsed.
- **Save failure.** The spend is still recorded. A same-call retry does
not pay again and reports the failure.
- **Binding gate.** Only Codex on a direct OpenAI API key gets the
binding. A subscription, ACPX, OpenRouter or a routed connection does
not.
- **Rate card math.**
- **Provider errors.** The error text does not contain the key, and
non-PNG data is rejected.
**Neighbouring suites:**
- `src/drivers/codex` in the runner package: 24 files, 350 tests passed.
- Server: `paperclip-runner-tool-authority.test.ts`,
`native-session-resume.test.ts`, `fast-responses.test.ts`,
`ai-connection-session.test.ts` and the new suite. 157 of 158 passed.
- The one failure is `native-session-resume.test.ts › archives a damaged
prior epoch … with real runnerd` (ENOENT on `authority-epochs`). It
fails the same way on unmodified `master` in this local environment, so
this change did not cause it.
- `tsc --noEmit -p server/tsconfig.json`: clean.
- `pnpm verify` exits 2 (broad). Required CI supplies the broad
coverage.
## Risks
- **New security surface: this needs maintainer sign-off.** The server
now sends the agent's OpenAI API key to `api.openai.com` on the agent's
request. The tool is limited in these ways:
- The key stays in the server.
- Each call checks run, task, budget, mutation and connection authority
again.
- The destination name comes from the server and must match
`^[a-z0-9-]+-[0-9a-f]{8}\.png$`. The write is create-only and confined
to the workspace root. On macOS it uses `O_NOFOLLOW_ANY`. On Linux it
uses a `/proc` descriptor walk.
- **Spend.** Each call costs money. The tool description tells the agent
to make only what the task needs. The cost is an estimate from a rate
card (`costStatus: "estimated"`), not an invoice. The rate card applies
the image-output rate to all output tokens, so the estimate errs on the
high side.
- **Checkpoint rotation.** The tool-contract bump from v18 to v19 makes
existing native provider threads start fresh once at their next turn.
This is what the contract requires when the tool surface changes.
- **Pricing drift.** If OpenAI changes `gpt-image-2` prices, the rate
card must be updated.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude
Code with tool use (shell, file edits, sub-agents) and extended
reasoning.
## 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 searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
336 lines
22 KiB
TypeScript
336 lines
22 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import { mkdtemp, readFile, readdir, realpath, rm } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { and, eq, sql } from "drizzle-orm";
|
|
import { activityLog, agents, authUsers, budgetPolicies, companies, companyMemberships, costEvents, createDb, heartbeatRuns, issues, projects } from "@paperclipai/db";
|
|
import { startRecoveryPostgres } from "../../__tests__/helpers/recovery-postgres.js";
|
|
import { aiConnectionService } from "../ai-connections.js";
|
|
import { accountRunCost } from "../run-cost-accounting.js";
|
|
import { budgetServiceInTransaction } from "../budgets.js";
|
|
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
|
|
import { imageGenerationBindingForRun, priceGeneratedImage, requestOpenAiImage } from "./generate-image-tool.js";
|
|
|
|
// One transparent 1x1 PNG. The handler only needs real PNG bytes to save.
|
|
const PNG = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64");
|
|
const OPENAI_KEY = "sk-agent-existing-provider-key";
|
|
|
|
describe("generate_image for OpenAI API-key Codex agents (N-12)", () => {
|
|
let temporary: Awaited<ReturnType<typeof startRecoveryPostgres>> | null = null;
|
|
let db: ReturnType<typeof createDb>;
|
|
let home: string;
|
|
const workspaces: string[] = [];
|
|
|
|
beforeAll(async () => {
|
|
home = await realpath(await mkdtemp(path.join(os.tmpdir(), "paperclip-image-tool-home-")));
|
|
vi.stubEnv("PAPERCLIP_HOME", home);
|
|
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "image-tool-fixture");
|
|
temporary = await startRecoveryPostgres("paperclip-image-tool-");
|
|
db = createDb(temporary.connectionString);
|
|
await db.insert(authUsers).values({ id: "alice", name: "Alice", email: "alice@example.test", createdAt: new Date(), updatedAt: new Date() });
|
|
}, 90_000);
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
afterAll(async () => {
|
|
await db?.$client.end({ timeout: 1 });
|
|
await temporary?.cleanup();
|
|
for (const workspace of workspaces) await rm(workspace, { recursive: true, force: true });
|
|
await rm(home, { recursive: true, force: true });
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
/** An onboarding-style company: one Codex agent running on an OpenAI API key. */
|
|
async function fixture() {
|
|
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "Pixel Bakery", issuePrefix: `PX${companyId.slice(0, 4).toUpperCase()}` });
|
|
await db.insert(companyMemberships).values({ companyId, principalId: "alice", principalType: "user", status: "active", membershipRole: "owner" });
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Head baker", adapterType: "paperclip_runner",
|
|
adapterConfig: { provider: "codex" }, status: "active" });
|
|
const connection = await aiConnectionService(db).save(companyId, "alice", {
|
|
provider: "openai", method: "api_key", name: "OpenAI", ownership: "shared",
|
|
apiKey: OPENAI_KEY, agentIds: [], allAgents: true,
|
|
} as never, OPENAI_KEY);
|
|
await db.insert(issues).values({ id: issueId, companyId, title: "Create printable cake menu and logo",
|
|
status: "in_progress", workMode: "standard", assigneeAgentId: agentId });
|
|
const aiConnection = { connectionId: connection.connectionId, grantId: connection.grantId,
|
|
provider: "openai", method: "api_key", mode: "shared", responsibleUserId: "alice" };
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", runtimeMode: "native",
|
|
nativeIssueId: issueId, invocationSource: "assignment", triggerDetail: "system", responsibleUserId: "alice",
|
|
contextSnapshot: { issueId, aiConnection } });
|
|
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
|
|
const workspaceRoot = await realpath(await mkdtemp(path.join(os.tmpdir(), "paperclip-image-tool-ws-")));
|
|
workspaces.push(workspaceRoot);
|
|
const binding = { companyId, agentId, issueId, runId, workspaceRoot,
|
|
imageGeneration: { connectionId: connection.connectionId, grantId: connection.grantId } };
|
|
return { ...binding, binding, aiConnection };
|
|
}
|
|
|
|
function stubOpenAiImages() {
|
|
const fetchMock = vi.fn(async (_url: string | URL | Request, _init?: RequestInit) => new Response(JSON.stringify({
|
|
created: 1, data: [{ b64_json: PNG.toString("base64") }],
|
|
usage: { input_tokens: 50, output_tokens: 1000, input_tokens_details: { text_tokens: 50, image_tokens: 0 } },
|
|
}), { status: 200, headers: { "content-type": "application/json", "x-request-id": "req_image_1" } }));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
return fetchMock;
|
|
}
|
|
|
|
it("lets an API-key Codex agent make an image with its existing OpenAI key, saved in its workspace and billed", async () => {
|
|
const f = await fixture();
|
|
const fetchMock = stubOpenAiImages();
|
|
const authority = new PaperclipRunnerToolAuthority(db, f.binding as never);
|
|
|
|
expect(authority.definitions().map(tool => tool.name)).toContain("generate_image");
|
|
|
|
const callId = randomUUID();
|
|
const result = await authority.execute({ tool: "generate_image", callId,
|
|
arguments: { prompt: "A warm, playful cake logo for Pixel Bakery", name: "pixel-bakery-logo" } }) as Record<string, unknown>;
|
|
|
|
// The trusted server made the call with the agent's existing key; no second key.
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const [url, init] = fetchMock.mock.calls[0]!;
|
|
expect(String(url)).toBe("https://api.openai.com/v1/images/generations");
|
|
expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${OPENAI_KEY}`);
|
|
expect(JSON.parse(String(init?.body))).toMatchObject({ model: "gpt-image-2", prompt: "A warm, playful cake logo for Pixel Bakery" });
|
|
|
|
// The PNG lands in the workspace under the returned relative path.
|
|
expect(result).toMatchObject({ outcome: "completed", contentType: "image/png", byteSize: PNG.length,
|
|
sha256: createHash("sha256").update(PNG).digest("hex") });
|
|
expect(String(result.path)).toMatch(/^pixel-bakery-logo-[a-f0-9]{8}\.png$/);
|
|
expect(await readFile(path.join(f.workspaceRoot, String(result.path)))).toEqual(PNG);
|
|
expect(JSON.stringify(result)).not.toContain(OPENAI_KEY);
|
|
|
|
// Spend is recorded on the budget ledger for this agent, run and task.
|
|
const costs = await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, f.runId));
|
|
expect(costs).toHaveLength(1);
|
|
expect(costs[0]).toMatchObject({ agentId: f.agentId, issueId: f.issueId, provider: "openai", biller: "openai",
|
|
billingType: "metered_api", model: "gpt-image-2", costStatus: "estimated", inputTokens: 50, outputTokens: 1000,
|
|
providerRequestId: "req_image_1" });
|
|
// 50 text tokens at $5/M + 1000 image output tokens at $30/M = $0.03025.
|
|
expect(Number(costs[0]!.costCents)).toBeCloseTo(3.025, 6);
|
|
|
|
const activity = await db.select().from(activityLog).where(and(eq(activityLog.companyId, f.companyId), eq(activityLog.action, "runner.image_generate")));
|
|
expect(activity.map(row => (row.details as Record<string, unknown>).outcome)).toEqual(expect.arrayContaining(["started", "completed"]));
|
|
expect(JSON.stringify(activity)).not.toContain(OPENAI_KEY);
|
|
|
|
// A provider retry of the same call replays the receipt instead of paying again.
|
|
const replay = await authority.execute({ tool: "generate_image", callId,
|
|
arguments: { prompt: "A warm, playful cake logo for Pixel Bakery", name: "pixel-bakery-logo" } });
|
|
expect(replay).toEqual(result);
|
|
expect(await authority.recover({ tool: "generate_image", callId,
|
|
arguments: { prompt: "A warm, playful cake logo for Pixel Bakery", name: "pixel-bakery-logo" } })).toEqual({ result });
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, f.runId))).toHaveLength(1);
|
|
});
|
|
|
|
it("is not offered without a server-verified OpenAI API-key binding", async () => {
|
|
const f = await fixture();
|
|
const { imageGeneration: _omitted, ...withoutBinding } = f.binding;
|
|
const authority = new PaperclipRunnerToolAuthority(db, withoutBinding);
|
|
expect(authority.definitions().map(tool => tool.name)).not.toContain("generate_image");
|
|
await expect(authority.execute({ tool: "generate_image", callId: randomUUID(), arguments: { prompt: "logo" } }))
|
|
.rejects.toThrow();
|
|
});
|
|
|
|
it("refuses before calling OpenAI once a budget hard-stop has paused the agent", async () => {
|
|
const f = await fixture();
|
|
const fetchMock = stubOpenAiImages();
|
|
await db.update(agents).set({ status: "paused" }).where(eq(agents.id, f.agentId));
|
|
const authority = new PaperclipRunnerToolAuthority(db, f.binding as never);
|
|
await expect(authority.execute({ tool: "generate_image", callId: randomUUID(), arguments: { prompt: "logo" } }))
|
|
.rejects.toThrow();
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(await readdir(f.workspaceRoot)).toEqual([]);
|
|
});
|
|
|
|
it("still records the spend when a paid image cannot be saved", async () => {
|
|
const f = await fixture();
|
|
const fetchMock = stubOpenAiImages();
|
|
await rm(f.workspaceRoot, { recursive: true, force: true });
|
|
const authority = new PaperclipRunnerToolAuthority(db, f.binding as never);
|
|
const callId = randomUUID();
|
|
await expect(authority.execute({ tool: "generate_image", callId, arguments: { prompt: "logo" } })).rejects.toThrow();
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, f.runId))).toHaveLength(1);
|
|
// The failed call is settled: a retry with the same call ID neither pays nor claims success.
|
|
await expect(authority.execute({ tool: "generate_image", callId, arguments: { prompt: "logo" } }))
|
|
.rejects.toThrow("could not be saved");
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("refuses before calling OpenAI when the task's project budget has stopped work", async () => {
|
|
const f = await fixture();
|
|
const fetchMock = stubOpenAiImages();
|
|
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Menu", pausedAt: new Date(), pauseReason: "budget" }).returning();
|
|
await db.update(issues).set({ projectId: project!.id }).where(eq(issues.id, f.issueId));
|
|
const authority = new PaperclipRunnerToolAuthority(db, f.binding as never);
|
|
await expect(authority.execute({ tool: "generate_image", callId: randomUUID(), arguments: { prompt: "logo" } }))
|
|
.rejects.toThrow("budget");
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("settles a paid image whose run stopped before the charge was recorded, without buying another", async () => {
|
|
const f = await fixture();
|
|
const fetchMock = stubOpenAiImages();
|
|
const authority = new PaperclipRunnerToolAuthority(db, f.binding as never);
|
|
const callId = randomUUID();
|
|
const input = { prompt: "logo", size: "1024x1024", quality: "auto" };
|
|
// The state a crash leaves after OpenAI charged but before accounting committed.
|
|
const key = createHash("sha256").update(callId).digest("hex");
|
|
const digest = createHash("sha256").update(JSON.stringify({ prompt: "logo", quality: "auto", size: "1024x1024" })).digest("hex");
|
|
await db.update(heartbeatRuns).set({ resultJson: { imageToolReceipts: { [key]: { digest, state: "charged", charge: {
|
|
model: "gpt-image-2", providerRequestId: "req_lost", usage: { textInputTokens: 10, imageInputTokens: 0, outputTokens: 100 },
|
|
costCents: "0.3050000", pricingProvenance: { source: "rate_card", version: "openai-image-standard-2026-11-06" },
|
|
projectId: null, destination: "generated-image-0a1b2c3d.png", byteSize: PNG.length, sha256: "0".repeat(64),
|
|
chargedAt: "2026-11-06T12:00:00.000Z" } } } } })
|
|
.where(eq(heartbeatRuns.id, f.runId));
|
|
expect(await authority.recover({ tool: "generate_image", callId, arguments: input })).toBeNull();
|
|
await expect(authority.execute({ tool: "generate_image", callId, arguments: input })).rejects.toThrow("generated-image-0a1b2c3d.png");
|
|
const costs = await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, f.runId));
|
|
expect(costs).toHaveLength(1);
|
|
expect(costs[0]).toMatchObject({ providerRequestId: "req_lost", outputTokens: 100 });
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("run accounting settles a paid image that no tool call settled", async () => {
|
|
const f = await fixture();
|
|
const key = createHash("sha256").update(randomUUID()).digest("hex");
|
|
await db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: new Date(), costAccountingPending: true,
|
|
resultJson: { imageToolReceipts: { [key]: { digest: "d", state: "charged", charge: {
|
|
model: "gpt-image-2", providerRequestId: "req_unsettled", usage: { textInputTokens: 10, imageInputTokens: 0, outputTokens: 100 },
|
|
costCents: "0.3050000", pricingProvenance: { source: "rate_card", version: "openai-image-standard-2026-11-06" },
|
|
projectId: null, destination: "generated-image-0a1b2c3d.png", byteSize: PNG.length, sha256: "0".repeat(64),
|
|
chargedAt: "2026-11-06T12:00:00.000Z" } } } } })
|
|
.where(eq(heartbeatRuns.id, f.runId));
|
|
await accountRunCost(db, f.runId);
|
|
const costs = await db.select().from(costEvents).where(and(eq(costEvents.heartbeatRunId, f.runId), eq(costEvents.providerRequestId, "req_unsettled")));
|
|
expect(costs).toHaveLength(1);
|
|
expect(costs[0]).toMatchObject({ agentId: f.agentId, issueId: f.issueId, model: "gpt-image-2", outputTokens: 100, costStatus: "estimated" });
|
|
// The task is deleted while the receipt is still `charged`; later accounting must not conflict.
|
|
await db.update(heartbeatRuns).set({ costAccountingPending: true, costAccountedAt: null, nativeIssueId: null }).where(eq(heartbeatRuns.id, f.runId));
|
|
await db.update(costEvents).set({ issueId: null }).where(eq(costEvents.id, costs[0]!.id));
|
|
await expect(accountRunCost(db, f.runId)).resolves.not.toThrow();
|
|
expect(await db.select().from(costEvents).where(and(eq(costEvents.heartbeatRunId, f.runId), eq(costEvents.providerRequestId, "req_unsettled")))).toHaveLength(1);
|
|
});
|
|
|
|
it("records unknown spend when OpenAI's response is lost after the request was sent", async () => {
|
|
const f = await fixture();
|
|
const fetchMock = vi.fn(async () => { throw new TypeError("fetch failed: socket hang up"); });
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const authority = new PaperclipRunnerToolAuthority(db, f.binding as never);
|
|
const callId = randomUUID();
|
|
await expect(authority.execute({ tool: "generate_image", callId, arguments: { prompt: "logo" } })).rejects.toThrow("may have been billed");
|
|
const costs = await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, f.runId));
|
|
expect(costs).toHaveLength(1);
|
|
expect(costs[0]).toMatchObject({ provider: "openai", model: "gpt-image-2", costStatus: "unpriced", billingType: "unknown" });
|
|
await expect(authority.execute({ tool: "generate_image", callId, arguments: { prompt: "logo" } })).rejects.toThrow("may have been billed");
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, f.runId))).toHaveLength(1);
|
|
// A budget that blocks unpriced spend now holds further paid work.
|
|
await db.insert(budgetPolicies).values({ companyId: f.companyId, scopeType: "agent", scopeId: f.agentId, windowKind: "calendar_month_utc",
|
|
amount: 100_000, unpricedUsagePolicy: "block" });
|
|
expect(await budgetServiceInTransaction(db).getInvocationBlock(f.companyId, f.agentId, { issueId: f.issueId })).toMatchObject({
|
|
reason: expect.stringContaining("no reliable price") });
|
|
});
|
|
|
|
it("records no spend when OpenAI rejects the request", async () => {
|
|
const f = await fixture();
|
|
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ error: { message: "Your request was rejected by the safety system" } }), { status: 400 })));
|
|
const authority = new PaperclipRunnerToolAuthority(db, f.binding as never);
|
|
await expect(authority.execute({ tool: "generate_image", callId: randomUUID(), arguments: { prompt: "logo" } })).rejects.toThrow("safety system");
|
|
expect(await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, f.runId))).toHaveLength(0);
|
|
});
|
|
|
|
it("does not call OpenAI when Stop commits after the first authority check", async () => {
|
|
const f = await fixture();
|
|
const fetchMock = stubOpenAiImages();
|
|
// Stop lands while the handler writes its receipt and audit entry.
|
|
await db.execute(sql.raw(`create or replace function n12_stop_on_image_start() returns trigger language plpgsql as $$
|
|
begin
|
|
if new.action = 'runner.image_generate' and new.details->>'outcome' = 'started' then
|
|
update agents set status = 'paused' where id = new.actor_id::uuid;
|
|
end if;
|
|
return new;
|
|
end $$;
|
|
create trigger n12_stop_on_image_start after insert on activity_log for each row execute function n12_stop_on_image_start();`));
|
|
try {
|
|
const authority = new PaperclipRunnerToolAuthority(db, f.binding as never);
|
|
await expect(authority.execute({ tool: "generate_image", callId: randomUUID(), arguments: { prompt: "logo" } })).rejects.toThrow();
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, f.runId))).toHaveLength(0);
|
|
} finally {
|
|
await db.execute(sql.raw("drop trigger if exists n12_stop_on_image_start on activity_log; drop function if exists n12_stop_on_image_start();"));
|
|
}
|
|
});
|
|
|
|
it("does not call OpenAI when the connection grant is revoked after the first check", async () => {
|
|
const f = await fixture();
|
|
const fetchMock = stubOpenAiImages();
|
|
await db.execute(sql.raw(`create or replace function n12_revoke_on_image_start() returns trigger language plpgsql as $$
|
|
begin
|
|
if new.action = 'runner.image_generate' and new.details->>'outcome' = 'started' then
|
|
update connection_grants set status = 'revoked' where id = '${f.binding.imageGeneration.grantId}';
|
|
end if;
|
|
return new;
|
|
end $$;
|
|
create trigger n12_revoke_on_image_start after insert on activity_log for each row execute function n12_revoke_on_image_start();`));
|
|
try {
|
|
const authority = new PaperclipRunnerToolAuthority(db, f.binding as never);
|
|
await expect(authority.execute({ tool: "generate_image", callId: randomUUID(), arguments: { prompt: "logo" } })).rejects.toThrow();
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, f.runId))).toHaveLength(0);
|
|
} finally {
|
|
await db.execute(sql.raw("drop trigger if exists n12_revoke_on_image_start on activity_log; drop function if exists n12_revoke_on_image_start();"));
|
|
}
|
|
});
|
|
|
|
it("refuses when the run's AI connection is no longer an active OpenAI API key", async () => {
|
|
const f = await fixture();
|
|
const fetchMock = stubOpenAiImages();
|
|
const authority = new PaperclipRunnerToolAuthority(db, { ...f.binding,
|
|
imageGeneration: { connectionId: f.binding.imageGeneration.connectionId, grantId: randomUUID() } } as never);
|
|
await expect(authority.execute({ tool: "generate_image", callId: randomUUID(), arguments: { prompt: "logo" } }))
|
|
.rejects.toThrow();
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("generate_image helpers", () => {
|
|
const aiConnection = { connectionId: "c1", grantId: "g1", provider: "openai", method: "api_key" };
|
|
|
|
it("binds only native Codex runs on a direct OpenAI API-key connection", () => {
|
|
expect(imageGenerationBindingForRun({ providerKind: "codex", contextSnapshot: { aiConnection } }))
|
|
.toEqual({ connectionId: "c1", grantId: "g1" });
|
|
// ChatGPT sign-in keeps Codex's own image tool; other harnesses and routed keys get nothing.
|
|
expect(imageGenerationBindingForRun({ providerKind: "codex", contextSnapshot: { aiConnection: { ...aiConnection, method: "subscription" } } })).toBeUndefined();
|
|
expect(imageGenerationBindingForRun({ providerKind: "acpx", contextSnapshot: { aiConnection } })).toBeUndefined();
|
|
expect(imageGenerationBindingForRun({ providerKind: "codex", contextSnapshot: { aiConnection: { ...aiConnection, provider: "openrouter" } } })).toBeUndefined();
|
|
expect(imageGenerationBindingForRun({ providerKind: "codex", contextSnapshot: { aiConnection, aiRouterSelection: { model: "x" } } })).toBeUndefined();
|
|
expect(imageGenerationBindingForRun({ providerKind: "codex", contextSnapshot: {} })).toBeUndefined();
|
|
});
|
|
|
|
it("prices gpt-image-2 from the reviewed rate card", () => {
|
|
const price = priceGeneratedImage({ bytes: PNG, model: "gpt-image-2", providerRequestId: null,
|
|
usage: { textInputTokens: 1_000_000, imageInputTokens: 1_000_000, outputTokens: 1_000_000 } });
|
|
expect(price.costUsd).toBe("43.000000000");
|
|
expect(price.pricingProvenance).toMatchObject({ source: "rate_card", version: "openai-image-standard-2026-11-06" });
|
|
});
|
|
|
|
it("stops reading an oversized provider reply before parsing it", async () => {
|
|
const huge = vi.fn(async () => new Response(new ReadableStream({ start(controller) {
|
|
for (let i = 0; i < 8; i++) controller.enqueue(new Uint8Array(1024));
|
|
controller.close();
|
|
} }), { status: 200 }));
|
|
await expect(requestOpenAiImage({ prompt: "logo", size: "1024x1024", quality: "auto", apiKey: OPENAI_KEY }, huge as never, { maxResponseBytes: 4096 }))
|
|
.rejects.toThrow("too large");
|
|
});
|
|
|
|
it("reports provider errors without echoing the key and rejects non-PNG data", async () => {
|
|
const failing = vi.fn(async () => new Response(JSON.stringify({ error: { message: "Billing hard limit reached" } }), { status: 400 }));
|
|
await expect(requestOpenAiImage({ prompt: "logo", size: "1024x1024", quality: "auto", apiKey: OPENAI_KEY }, failing as never))
|
|
.rejects.toThrow("OpenAI image generation failed: Billing hard limit reached");
|
|
const notPng = vi.fn(async () => new Response(JSON.stringify({ data: [{ b64_json: Buffer.from("<svg/>").toString("base64") }] }), { status: 200 }));
|
|
await expect(requestOpenAiImage({ prompt: "logo", size: "1024x1024", quality: "auto", apiKey: OPENAI_KEY }, notPng as never))
|
|
.rejects.toThrow("invalid PNG");
|
|
});
|
|
});
|