Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
00d8054
feat(codex): serve provider-relative image requests
Menci Jul 14, 2026
b198c20
fix(codex): type image edit bytes and error fixtures
Menci Jul 14, 2026
6c981dd
refactor(images): share edit source preparation
Menci Jul 14, 2026
077c9b9
style(images): satisfy workspace lint rules
Menci Jul 14, 2026
113be18
refactor(codex): reuse materialized image buffers
Menci Jul 14, 2026
d80a4aa
refactor(images): type prepared edit payloads
Menci Jul 14, 2026
c912fae
refactor(codex): require prepared image files
Menci Jul 14, 2026
7a167bb
test(images): colocate edit source coverage
Menci Jul 14, 2026
f7c23f3
refactor(images): unify JSON edit handling
Menci Jul 15, 2026
874544e
style(images): await JSON provider dispatch
Menci Jul 15, 2026
dd55033
docs(images): clarify dual-format buffering
Menci Jul 15, 2026
4bbc044
docs(images): keep buffering rationale concise
Menci Jul 15, 2026
a54e28d
refactor(images): dispatch edits through semantic requests
Menci Jul 15, 2026
9664407
style(provider): order image request import
Menci Jul 15, 2026
446225f
test(images): assert canonical upload field
Menci Jul 15, 2026
d6f30f5
refactor(images): enforce prepared edit invariants
Menci Jul 15, 2026
277f936
refactor(images): complete edit request invariants
Menci Jul 15, 2026
0b8cdd4
refactor(images): classify inline data as uploads
Menci Jul 15, 2026
cc98076
style(images): narrow inline data URL captures
Menci Jul 15, 2026
d1488eb
refactor(images): model inline sources without duplication
Menci Jul 15, 2026
1d35c72
fix(images): type decoded bytes as array buffers
Menci Jul 15, 2026
3a2880e
fix(images): preserve decoded buffer ownership
Menci Jul 15, 2026
0725cb3
refactor(images): preflight edit serialization
Menci Jul 15, 2026
854bc6b
refactor(images): centralize multipart selection
Menci Jul 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ target ships in the same repo for self-hosting on a long-lived process.
| OpenAI Models | `GET /v1/models` |
| Google Gemini (generate / count tokens) | `POST /v1beta/models/...` |

`POST /v1/images/edits` accepts multipart image uploads and JSON `images`
references. The dashboard's Codex provider base, `/azure-api.codex`, exposes
the same generation and edit handlers at their provider-relative paths.

For each public model, Floway picks the first (provider, model) pair that can
serve the request, translating between source and target protocols when the
upstream speaks a different shape. `/v1/completions` is forwarded to upstreams that
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { createInMemoryImageProcessor, initExternalResourceFetcher, initImagePro
import { eventFrame } from '@floway-dev/protocols/common';
import type { ProtocolFrame } from '@floway-dev/protocols/common';
import type { ResponsesResult, ResponsesStreamEvent, ResponsesTool, ResponsesToolChoice } from '@floway-dev/protocols/responses';
import { type EventResult, type ExecuteResult, type FlagId } from '@floway-dev/provider';
import { type EventResult, type ExecuteResult, type FlagId, type ImagesEditsRequest } from '@floway-dev/provider';
import { assert, assertEquals, assertStringIncludes, stubModelCandidate } from '@floway-dev/test-utils';

// Dirty integration harness: mock the model registry so the image backend is a
Expand All @@ -20,7 +20,7 @@ import { assert, assertEquals, assertStringIncludes, stubModelCandidate } from '

interface BackendStub {
generationsCalls: Record<string, unknown>[];
editsForms: FormData[];
editsRequests: ImagesEditsRequest[];
nextGenerations: Response[];
nextEdits: Response[];
// When set, the next `enumerateModelCandidates` call returns this
Expand All @@ -31,7 +31,7 @@ interface BackendStub {

// Hoisted so the vi.mock factory below can close over it; tests mutate the
// `next*` queues and read back the recorded calls.
const stub = vi.hoisted((): BackendStub => ({ generationsCalls: [], editsForms: [], nextGenerations: [], nextEdits: [], nextResolutionOverride: null }));
const stub = vi.hoisted((): BackendStub => ({ generationsCalls: [], editsRequests: [], nextGenerations: [], nextEdits: [], nextResolutionOverride: null }));

// Assigned per test in beforeEach and captured so the perf-attribution test
// can read `repo.performance.listAll()` after the shim completes.
Expand All @@ -52,8 +52,8 @@ const defaultCandidates = vi.hoisted(() => () => [{
if (response === undefined) throw new Error('test did not enqueue a generations response');
return { response, modelKey: 'gpt-image-2' };
},
callImagesEdits: async (_model: unknown, form: FormData) => {
stub.editsForms.push(form);
callImagesEdits: async (_model: unknown, request: ImagesEditsRequest) => {
stub.editsRequests.push(request);
const response = stub.nextEdits.shift();
if (response === undefined) throw new Error('test did not enqueue an edits response');
return { response, modelKey: 'gpt-image-2' };
Expand Down Expand Up @@ -221,22 +221,26 @@ beforeEach(async () => {
initRepo(repo);
initImageProcessor(createInMemoryImageProcessor());
stub.generationsCalls = [];
stub.editsForms = [];
stub.editsRequests = [];
stub.nextGenerations = [];
stub.nextEdits = [];
stub.nextResolutionOverride = null;
});

test('generates an image end-to-end and emits the native lifecycle', async () => {
stub.nextGenerations = [jsonResponse('R0VO')]; // "GEN"
const result = await shim(makeCtx([{ type: 'message', role: 'user', content: 'draw a cat' }]), gatewayCtx(), scriptedRun([
const result = await shim(makeCtx([{ type: 'message', role: 'user', content: 'draw a cat' }], 'auto', {
size: '1024x1024',
quality: 'low',
}), gatewayCtx(), scriptedRun([
callTurn(0, 'call_1', 'a cat'),
messageTurn('here it is'),
]));
const events = await drain(result);

assertEquals(stub.generationsCalls.length, 1);
assertEquals(stub.editsForms.length, 0);
assertEquals(stub.generationsCalls[0], { prompt: 'a cat', n: 1, size: '1024x1024', quality: 'low' });
assertEquals(stub.editsRequests.length, 0);
const igcDone = events.find(e => e.type === 'response.output_item.done' && (e as { item: { type: string } }).item.type === 'image_generation_call');
assert(igcDone !== undefined);
const item = (igcDone as { item: { status: string; result: string; action: string } }).item;
Expand Down Expand Up @@ -275,6 +279,7 @@ test('relays real partial_image frames when partial_images > 0', async () => {
]));
const events = await drain(result);

assertEquals(stub.generationsCalls[0], { prompt: 'a cat', n: 1, stream: true, partial_images: 2 });
const partials = events.filter(e => e.type === 'response.image_generation_call.partial_image');
assertEquals(partials.length, 2);
assertEquals((partials[0] as { partial_image_b64: string }).partial_image_b64, 'UDA=');
Expand All @@ -298,10 +303,12 @@ test('an image generated in turn 1 is re-collected as an edit source in turn 2',
await drain(result);

assertEquals(stub.generationsCalls.length, 1);
assertEquals(stub.editsForms.length, 1);
const images = stub.editsForms[0].getAll('image[]');
assertEquals(images.length, 1);
const bytes = await (images[0] as Blob).text();
assertEquals(stub.editsRequests.length, 1);
const request = stub.editsRequests[0];
assertEquals(request.images.length, 1);
const image = request.images[0];
assert(image.type === 'upload');
const bytes = await image.file.text();
assertEquals(bytes, 'AAAA');
});

Expand Down Expand Up @@ -337,9 +344,11 @@ test('a prefetched remote edit source remains visible to orchestration and is re

assertEquals(fetched, ['https://example.com/source.png']);
assertEquals(orchestratorImageUrl, 'https://example.com/source.png');
assertEquals(stub.editsForms.length, 1);
const image = stub.editsForms[0].getAll('image[]')[0] as Blob;
assertEquals(new Uint8Array(await image.arrayBuffer()), Uint8Array.from(atob(REMOTE_PNG_B64), c => c.charCodeAt(0)));
assertEquals(stub.editsRequests.length, 1);
const request = stub.editsRequests[0];
const image = request.images[0];
assert(image.type === 'upload');
assertEquals(new Uint8Array(await image.file.arrayBuffer()), Uint8Array.from(atob(REMOTE_PNG_B64), c => c.charCodeAt(0)));
});

test('mask-only GIF edit transcodes one shared image and mask to WebP', async () => {
Expand All @@ -361,14 +370,16 @@ test('mask-only GIF edit transcodes one shared image and mask to WebP', async ()
await drain(result);

assertEquals(processorCalls, 1);
assertEquals(stub.editsForms.length, 1);
const form = stub.editsForms[0];
const image = form.getAll('image[]')[0] as Blob;
const mask = form.get('mask') as Blob;
assertEquals(image.type, 'image/webp');
assertEquals(mask.type, 'image/webp');
assertEquals(await image.text(), 'WEBP');
assertEquals(await mask.text(), 'WEBP');
assertEquals(stub.editsRequests.length, 1);
const request = stub.editsRequests[0];
const image = request.images[0];
const mask = request.mask;
assert(image.type === 'upload');
assert(mask?.type === 'upload');
assertEquals(image.file.type, 'image/webp');
assertEquals(mask.file.type, 'image/webp');
assertEquals(await image.file.text(), 'WEBP');
assertEquals(await mask.file.text(), 'WEBP');
});

test('identical GIF source and mask share one transcode', async () => {
Expand All @@ -393,9 +404,12 @@ test('identical GIF source and mask share one transcode', async () => {
await drain(result);

assertEquals(processorCalls, 1);
const form = stub.editsForms[0];
assertEquals(await (form.getAll('image[]')[0] as Blob).text(), 'WEBP');
assertEquals(await (form.get('mask') as Blob).text(), 'WEBP');
const request = stub.editsRequests[0];
const image = request.images[0];
assert(image.type === 'upload');
assert(request.mask?.type === 'upload');
assertEquals(await image.file.text(), 'WEBP');
assertEquals(await request.mask.file.text(), 'WEBP');
});

test('image transcoding failure becomes a terminal image tool failure', async () => {
Expand All @@ -412,7 +426,7 @@ test('image transcoding failure becomes a terminal image tool failure', async ()
]));
const events = await drain(result);

assertEquals(stub.editsForms.length, 0);
assertEquals(stub.editsRequests.length, 0);
const done = events.find(event => event.type === 'response.output_item.done'
&& (event as { item: { type: string } }).item.type === 'image_generation_call');
assert(done !== undefined);
Expand Down
Loading