An image counted as half a million tokens: the context estimate measured its base64 as text, so a 1-3 MB screenshot read as "context at 150-280%" the moment view_image added it, and the session compacted after every image. An image now counts by its dimensions (28 px patches, at most 16k tokens, a flat 1600 when unreadable) everywhere the CLI estimates -- the meter, pruning, /context and the no-usage fallback of all five dialects. Images over 1600 px or 500 KB are shrunk to 1600 px when ImageMagick is there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
12 files changed
+196
-18
No files matched your search
@@ -0,0 +1,67 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { createApp } from "../src/app.ts"
|
||||
import type { AskReply, Event } from "../src/bus/index.ts"
|
||||
import { paths } from "../src/config/paths.ts"
|
||||
import { estimateValue, IMAGE_FALLBACK_TOKENS, imageSize, imageTokens } from "../src/provider/tokens.ts"
|
||||
import { delta, fakeProvider, toolCall, usage, type Fake } from "./fake-provider.ts"
|
||||
|
||||
/** A PNG-shaped file of `bytes` bytes that says it is `w`×`h` (header only; the rest is filler). */
|
||||
function png(w: number, h: number, bytes: number): Buffer {
|
||||
const out = Buffer.alloc(bytes, 0x41)
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]).copy(out, 0)
|
||||
out.writeUInt32BE(w, 16)
|
||||
out.writeUInt32BE(h, 20)
|
||||
return out
|
||||
}
|
||||
|
||||
let fake: Fake | undefined
|
||||
afterEach(() => fake?.stop())
|
||||
|
||||
describe("an image counts by its dimensions, never its encoding", () => {
|
||||
test("sizes are read from PNG, GIF and JPEG headers", () => {
|
||||
expect(imageSize(png(2560, 1440, 64))).toEqual({ width: 2560, height: 1440 })
|
||||
const gif = Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x40, 0x01, 0xf0, 0x00])
|
||||
expect(imageSize(gif)).toEqual({ width: 320, height: 240 })
|
||||
// SOI, an APP0 segment of 16 bytes, then SOF0 with height 600 and width 800.
|
||||
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, ...Array(14).fill(0), 0xff, 0xc0, 0x00, 0x11, 0x08, 0x02, 0x58, 0x03, 0x20, 0x03])
|
||||
expect(imageSize(jpeg)).toEqual({ width: 800, height: 600 })
|
||||
})
|
||||
|
||||
test("a 1.5 MB 2560×1440 screenshot is thousands of tokens, not half a million", () => {
|
||||
const data = png(2560, 1440, 1_500_000).toString("base64")
|
||||
expect(imageTokens(data)).toBe(Math.ceil(2560 / 28) * Math.ceil(1440 / 28))
|
||||
const message = { role: "user", parts: [{ type: "text", text: "look" }, { type: "image", mime: "image/png", data }] }
|
||||
expect(estimateValue(message)).toBeLessThan(5_000)
|
||||
// The same image in each dialect's wire shape.
|
||||
expect(estimateValue([{ content: [{ type: "image_url", image_url: { url: `data:image/png;base64,${data}` } }] }])).toBeLessThan(5_000)
|
||||
expect(estimateValue([{ content: [{ type: "image", source: { type: "base64", media_type: "image/png", data } }] }])).toBeLessThan(5_000)
|
||||
expect(estimateValue([{ role: "user", content: "look", images: [data] }])).toBeLessThan(5_000)
|
||||
})
|
||||
|
||||
test("an image whose size cannot be read has a flat cost", () => {
|
||||
expect(imageTokens(Buffer.alloc(300_000, 7).toString("base64"))).toBe(IMAGE_FALLBACK_TOKENS)
|
||||
})
|
||||
|
||||
test("view_image of a 1.5 MB screenshot at 20% of the window does not compact", async () => {
|
||||
fake = fakeProvider([
|
||||
{ chunks: [toolCall(0, "c1", "view_image", '{"path":"shot.png"}'), usage(52_000, 50)] },
|
||||
{ chunks: [delta({ content: "a screenshot" }), usage(56_000, 5)] },
|
||||
])
|
||||
mkdirSync(paths.config, { recursive: true })
|
||||
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n f:\n dialect: openai-chat\n base_url: ${fake.url}\n models:\n m: { vision: true, context: 262144 }\n`, { mode: 0o600 })
|
||||
writeFileSync(join(paths.config, "config.yaml"), "model: f/m\n")
|
||||
const root = mkdtempSync(join(tmpdir(), "ph-img-"))
|
||||
writeFileSync(join(root, "shot.png"), png(2560, 1440, 1_500_000))
|
||||
const app = createApp({ cwd: root, mode: "edit", store: false, asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) } })
|
||||
const notices: string[] = []
|
||||
app.bus.on((e: Event) => {
|
||||
if (e.type === "notice") notices.push(e.message)
|
||||
})
|
||||
await app.engine.prompt("look at shot.png")
|
||||
expect(notices.filter((n) => n.includes("compacting") || n.includes("pruned"))).toEqual([])
|
||||
expect(fake.requests.length).toBe(2)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user