diff --git a/apps/web/__tests__/components/requests/detail_test.tsx b/apps/web/__tests__/components/requests/detail_test.tsx index f73c46275f..ae03a1d51e 100644 --- a/apps/web/__tests__/components/requests/detail_test.tsx +++ b/apps/web/__tests__/components/requests/detail_test.tsx @@ -4,17 +4,35 @@ import { describe, expect, it, vi } from 'vitest'; import { RequestDetailPanel } from '../../../src/components/requests/detail'; import { renderInApp } from '../../render'; -import type { DumpRecord } from '@floway-dev/gateway/browser'; +import type { DumpEdgeRecord, DumpRunRecord } from '@floway-dev/gateway/browser'; vi.mock('../../../src/components/ui/body-editor', () => ({ default: ({ text, toolbarStart }: { text: string; toolbarStart?: ReactNode }) => <>{toolbarStart}
{text}
})); -const record: DumpRecord = { +const record: DumpEdgeRecord = { + shape: 'edge', meta: { id: 'detail', method: 'POST', path: '/v1/chat/completions', startedAt: 0, completedAt: 1, status: 200, upstream: null, model: 'm', inputTokens: null, outputTokens: null, requestBytes: 0, responseBytes: 0, durationMs: 1, error: null }, request: { method: 'POST', path: '/v1/chat/completions', headers: [], body: { encoding: 'utf8', data: '{"client":"large request"}' } }, response: { status: 200, headers: [], body: { type: 'stream', events: [] } }, capture: { exchanges: [{ upstreamId: 'u', request: { url: 'https://upstream.test', method: 'POST', headers: [], body: { encoding: 'utf8', data: '{"upstream":"translated request"}' } }, response: { status: 200, headers: [], body: { encoding: 'utf8', data: 'data: {broken\n' }, complete: false, error: null }, error: null }], response: { body: { encoding: 'utf8', data: 'data: downstream\n' }, complete: true, error: null } }, }; +it('opens stage facts and offers searchable events with timing and export controls', async () => { + const run: DumpRunRecord = { + shape: 'run', meta: { ...record.meta, ttftMs: 125 }, + events: '{"type":"stage.entered","stageId":1,"name":"serve","parentStageId":null,"facts":{"serve.model":"m"}}\n', + }; + renderInApp(); + expect(screen.getByRole('treeitem', { name: 'serve #1' })).toBeTruthy(); + expect((await screen.findByTestId('body-content')).textContent).toContain('serve.model'); + fireEvent.click(screen.getByRole('combobox', { name: 'Run' })); + fireEvent.click(screen.getByRole('option', { name: 'Events (1)' })); + expect(screen.getByRole('button', { name: '#1 stage.entered serve' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Search events' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Export record' })).toBeTruthy(); + expect(screen.getByText('125ms')).toBeTruthy(); + expect(screen.queryByRole('combobox', { name: 'Request details' })).toBeNull(); +}); + describe('request detail navigation', () => { it('opens each body directly and preserves malformed raw response text', async () => { renderInApp(); @@ -65,3 +83,15 @@ it('switches upstream attempts without showing the last attempt’s payload', as fireEvent.click(screen.getAllByRole('option')[0]!); expect((await screen.findByTestId('body-content')).textContent).toContain('first attempt'); }); + +it('masks credential headers in rendered run facts', () => { + const secret = 'Bearer private-request-token-1234567890'; + const events = [ + { type: 'object', fromObjectId: 1, nodes: [[['authorization', secret]]] }, + { type: 'stage.entered', stageId: 1, name: 'serve', parentStageId: null, facts: { 'ingress.http.headers': { $: 1 } } }, + ].map(event => `${JSON.stringify(event)}\n`).join(''); + const view = renderInApp(); + expect(view.container.textContent).not.toContain(secret); + expect(view.container.textContent).toContain('authorization'); + expect(view.container.textContent).toContain('••••'); +}); diff --git a/apps/web/__tests__/components/requests/dump-stream-stub.ts b/apps/web/__tests__/components/requests/dump-stream-stub.ts index 6a5adbfe8f..cbe0d867bc 100644 --- a/apps/web/__tests__/components/requests/dump-stream-stub.ts +++ b/apps/web/__tests__/components/requests/dump-stream-stub.ts @@ -1,7 +1,7 @@ import { act } from '@testing-library/react'; import { afterEach, beforeEach, expect } from 'vitest'; -import type { DumpMetadata } from '@floway-dev/gateway/browser'; +import type { DumpMetadata } from '@floway-dev/dump/browser'; export const record = (id: string): DumpMetadata => ({ id, diff --git a/apps/web/__tests__/components/requests/dump-subscription-key-switch_test.tsx b/apps/web/__tests__/components/requests/dump-subscription-key-switch_test.tsx index 2a6f27177a..eeeff3594d 100644 --- a/apps/web/__tests__/components/requests/dump-subscription-key-switch_test.tsx +++ b/apps/web/__tests__/components/requests/dump-subscription-key-switch_test.tsx @@ -7,7 +7,7 @@ import { flowayTokenStorageKey } from '../../../src/auth/session'; import { useDumpSubscription } from '../../../src/components/requests/use-dump-subscription'; import { stubLocalStorage } from '../../local-storage-stub'; import { renderInApp } from '../../render'; -import type { DumpMetadata } from '@floway-dev/gateway/browser'; +import type { DumpMetadata } from '@floway-dev/dump/browser'; interface Commit { ids: string[]; keyId: string | null } interface Selection { keyId: string | null; seed: DumpMetadata[] } @@ -102,6 +102,15 @@ describe('dump subscription key switch', () => { expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['/v1/key-a-2', '/v1/key-a-1']); }); + it('preserves older completed records when run IDs were assigned in a different start order', () => { + const older = { ...record('z-started-later'), completedAt: 100 }; + const removed = { ...record('a-started-earlier'), completedAt: 300 }; + renderSubscription('key-a', [removed, older]); + const recent = { ...record('m-long-running'), completedAt: 200 }; + stream.liveSource().emit('snapshot', JSON.stringify({ records: [recent] })); + expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['/v1/m-long-running', '/v1/z-started-later']); + }); + it('throws when the session token is gone', () => { storage.delete(flowayTokenStorageKey); diff --git a/apps/web/__tests__/components/requests/export_test.ts b/apps/web/__tests__/components/requests/export_test.ts index 7f33bcf030..99010a48d1 100644 --- a/apps/web/__tests__/components/requests/export_test.ts +++ b/apps/web/__tests__/components/requests/export_test.ts @@ -2,19 +2,20 @@ import { describe, expect, it } from 'vitest'; import { exportRecords } from '../../../src/components/requests/export'; import { redactHeaderValue } from '../../../src/components/requests/header-redact'; -import type { DumpRecord } from '@floway-dev/gateway/browser'; +import type { DumpEdgeRecord } from '@floway-dev/gateway/browser'; const SECRET = 'Bearer client-secret-token-1234567890abcd'; const MASK = redactHeaderValue(SECRET); -const record = (id: string): DumpRecord => ({ +const record = (id: string): DumpEdgeRecord => ({ + shape: 'edge', meta: { id, method: 'POST', path: '/v1/chat/completions', startedAt: 0, completedAt: 1000, status: 200, upstream: null, model: 'm', inputTokens: null, outputTokens: null, requestBytes: 0, responseBytes: 0, durationMs: 1000, error: null }, request: { method: 'POST', path: '/v1/chat/completions', headers: [['authorization', SECRET], ['content-type', 'application/json']], body: { encoding: 'utf8', data: '中文 request' } }, response: { status: 200, headers: [['set-cookie', SECRET]], body: { type: 'none' }, upstream: { status: 200, headers: [['api-key', SECRET]], body: { type: 'none' } } }, capture: { exchanges: [{ upstreamId: 'u', request: { url: 'https://upstream.test', method: 'POST', headers: [['x-api-key', SECRET]], body: { encoding: 'utf8', data: '{"upstream":"translated request"}' } }, response: { status: 200, headers: [['x-goog-api-key', SECRET]], body: { encoding: 'utf8', data: 'data: {broken\n' }, complete: true, error: null }, error: null }], response: { body: { encoding: 'base64', data: '/4A=' }, complete: false, error: 'socket reset' } }, }); -const redactedRecord = (id: string): DumpRecord => { +const redactedRecord = (id: string): DumpEdgeRecord => { const source = record(id); return { ...source, diff --git a/apps/web/__tests__/components/requests/run-model_test.ts b/apps/web/__tests__/components/requests/run-model_test.ts new file mode 100644 index 0000000000..97ebc05653 --- /dev/null +++ b/apps/web/__tests__/components/requests/run-model_test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; + +import { readRun } from '../../../src/components/requests/run-model'; + +describe('run stage model', () => { + it('restores inherited entries, repeated descents and folded returns', () => { + const model = readRun([ + { type: 'object', fromObjectId: 1, nodes: [{ text: 'before' }, { text: 'after' }] }, + { type: 'stage.entered', stageId: 1, name: 'fork', parentStageId: null, facts: { request: { $: 1 } } }, + { type: 'stage.entered', stageId: 2, name: 'first', parentStageId: 1 }, + { type: 'stage.leaved', stageId: 2, facts: { response: 'retry' } }, + { type: 'stage.entered', stageId: 3, name: 'second', parentStageId: 1, facts: { request: { $: 2 } } }, + { type: 'stage.leaved', stageId: 3, facts: { response: 'answer' } }, + ].map(event => JSON.stringify(event)).join('\n')); + const fork = model.roots[0]!; + expect(fork.children.map(stage => stage.id)).toEqual([2, 3]); + expect(fork.children[0]!.request).toBe(fork.request); + expect(model.state(fork.response!)).toEqual({ response: 'answer' }); + expect(model.diff(fork.request, fork.children[1]!.request)).toEqual([ + { path: ['request', 'text'], kind: 'changed', before: 'before', after: 'after' }, + ]); + }); + + it('does not fold a successful child response into a parent that subsequently failed', () => { + const model = readRun([ + { type: 'object', fromObjectId: 1, nodes: [{ $error: { name: 'Error', message: 'projection failed', stack: 'stack' } }] }, + { type: 'stage.entered', stageId: 1, name: 'projection', parentStageId: null, facts: {} }, + { type: 'stage.entered', stageId: 2, name: 'answer', parentStageId: 1 }, + { type: 'stage.leaved', stageId: 2, facts: { answer: 'upstream value' } }, + { type: 'stage.failed', stageId: 1, error: { $: 1 } }, + ].map(event => JSON.stringify(event)).join('\n')); + const parent = model.roots[0]!; + expect(parent.response).toBeNull(); + expect(parent.children[0]!.response).toEqual({ answer: 'upstream value' }); + expect(model.state(parent.failure!)).toMatchObject({ error: { $error: { message: 'projection failed' } } }); + }); + + it('uses shared object identities, handles cycles and decodes schema keys', () => { + const model = readRun([ + { type: 'object', fromObjectId: 1, nodes: [{ '$$ref': 'schema', self: { $: 1 } }, { '$$ref': 'next', self: { $: 2 } }] }, + { type: 'stage.entered', stageId: 1, name: 'stage', parentStageId: null, facts: { same: { $: 1 }, changed: { $: 1 } } }, + { type: 'stage.leaved', stageId: 1, facts: { same: { $: 1 }, changed: { $: 2 }, added: { $undefined: true } } }, + ].map(event => JSON.stringify(event)).join('\n')); + const stage = model.roots[0]!; + expect(model.state(stage.request)).toEqual({ + same: { $ref: 'schema', self: { $cycle: 1 } }, changed: { $ref: 'schema', self: { $cycle: 1 } }, + }); + expect(model.diff(stage.request, stage.response!)).toEqual([ + { path: ['changed', '$ref'], kind: 'changed', before: 'schema', after: 'next' }, + { path: ['added'], kind: 'added', after: { $undefined: true } }, + ]); + }); + it('reports both changed aliases and displays a late deferred outcome', () => { + const model = readRun([ + { type: 'object', fromObjectId: 1, nodes: [{ value: 'old' }, { value: 'new' }, { $deferred: true }, { status: 'fulfilled', value: { usage: 5 } }] }, + { type: 'stage.entered', stageId: 1, name: 'stage', parentStageId: null, facts: { first: { $: 1 }, second: { $: 1 } } }, + { type: 'stage.leaved', stageId: 1, facts: { first: { $: 2 }, second: { $: 2 }, pending: { $: 3 } } }, + { type: 'deferred.settled', deferred: { $: 3 }, outcome: { $: 4 } }, + ].map(event => JSON.stringify(event)).join('\n')); + const stage = model.roots[0]!; + expect(model.diff(stage.request, stage.response!).slice(0, 2)).toEqual([ + { path: ['first', 'value'], kind: 'changed', before: 'old', after: 'new' }, + { path: ['second', 'value'], kind: 'changed', before: 'old', after: 'new' }, + ]); + expect(model.state(stage.response!)).toMatchObject({ pending: { $deferred: { status: 'fulfilled', value: { usage: 5 } } } }); + }); + + it('resolves referenced protocol streams through aliases and cycles without treating schema keys as stream tags', () => { + const model = readRun([ + { + type: 'object', fromObjectId: 1, nodes: [ + { stream: { $stream: 2 }, '$$stream': 999, self: { $: 1 } }, + { type: 'event', event: { type: 'citation', source: 'https://example.test', '$$schema': 'value' } }, + ], + }, + { type: 'stage.entered', stageId: 1, name: 'translate', parentStageId: null, facts: { source: { $: 1 } } }, + { type: 'stage.leaved', stageId: 1, facts: { source: { $: 1 }, client: { $stream: 3 } } }, + { type: 'stream.frame', streamId: 2, frames: [{ $: 2 }] }, + { type: 'stream.end', streamId: 2 }, + ].map(event => JSON.stringify(event)).join('\n')); + const stage = model.roots[0]!; + const streams = model.referencedStreams(stage.request, stage.response!); + expect(streams.map(stream => ({ id: stream.id, ended: stream.ended }))).toEqual([{ id: 2, ended: true }, { id: 3, ended: false }]); + expect(model.frameValue(streams[0]!.frames[0]!)).toEqual({ type: 'event', event: { type: 'citation', source: 'https://example.test', $schema: 'value' } }); + expect(streams[1]!.frames).toEqual([]); + }); + +}); diff --git a/apps/web/__tests__/components/requests/run-redact_test.ts b/apps/web/__tests__/components/requests/run-redact_test.ts new file mode 100644 index 0000000000..ead440d782 --- /dev/null +++ b/apps/web/__tests__/components/requests/run-redact_test.ts @@ -0,0 +1,30 @@ +import { expect, it } from 'vitest'; + +import { redactHeaderValue } from '../../../src/components/requests/header-redact'; +import { redactRunHeaders } from '../../../src/components/requests/run-redact'; + +it('redacts shared header string definitions while preserving unrelated payloads and references', () => { + const secret = 'Bearer shared-token'.repeat(100); + const events = [ + { type: 'object', fromObjectId: 1, nodes: [[{ $: 2 }, { $: 4 }], ['authorization', { $: 3 }], secret, ['content-type', 'application/json']] }, + { type: 'stage.entered', stageId: 1, name: 'entry', parentStageId: null, facts: { 'ingress.http.headers': { $: 1 }, 'request.payload': { $: 5 } } }, + { type: 'object', fromObjectId: 5, nodes: [{ text: 'payload remains exact' }] }, + { type: 'stage.leaved', stageId: 1, facts: { 'response.http.headers': { $: 1 } } }, + ]; + const source = events.map(event => `${JSON.stringify(event)}\n`).join(''); + const exported = redactRunHeaders(source); + expect(exported).not.toContain(secret); + expect(exported).toContain(redactHeaderValue(secret)); + expect(exported).toContain('payload remains exact'); + expect(JSON.parse(exported.split('\n')[0]!).nodes[1]).toEqual(['authorization', { $: 3 }]); + expect(source).toContain(secret); +}); + +it('preserves already marked secrets and rejects missing header references', () => { + const marked = [ + { type: 'object', fromObjectId: 1, nodes: [[{ $: 2 }], ['authorization', { $: 3 }], { $secret: { length: 10, redacted: '**********', hash: '0x123' } }] }, + { type: 'stage.leaved', stageId: 1, facts: { 'request.http.headers': { $: 1 } } }, + ].map(event => `${JSON.stringify(event)}\n`).join(''); + expect(redactRunHeaders(marked)).toBe(marked); + expect(() => redactRunHeaders('{"type":"stage.leaved","stageId":1,"facts":{"request.http.headers":{"$":99}}}\n')).toThrow('missing object 99'); +}); diff --git a/apps/web/__tests__/components/requests/run-render_test.ts b/apps/web/__tests__/components/requests/run-render_test.ts new file mode 100644 index 0000000000..74e5e676e3 --- /dev/null +++ b/apps/web/__tests__/components/requests/run-render_test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { renderRunEvents } from '../../../src/components/requests/run-render'; + +// The stored record is NDJSON, one event per line, and each line is also one SSE +// `data:` payload — so this is the same reading a live observer will do. +describe('run event reader', () => { + it('names what each of the six event kinds is about', () => { + const events = renderRunEvents([ + '{"type":"stage.entered","stageId":1,"name":"serve","parentStageId":null}', + '{"type":"stage.leaved","stageId":1,"facts":{}}', + '{"type":"stage.log","stageId":1,"level":"warn","message":"retrying"}', + '{"type":"object","fromObjectId":97,"nodes":[]}', + '{"type":"stream.frame","streamId":3,"frames":[]}', + '{"type":"stream.end","streamId":3}', + '', + ].join('\n')); + + expect(events.map(event => [event.type, event.subject])).toEqual([ + ['stage.entered', 'serve'], + ['stage.leaved', '#1'], + ['stage.log', 'warn'], + ['object', '#97'], + ['stream.frame', '#3'], + ['stream.end', '#3'], + ]); + expect(events.every(event => event.parseError === null)).toBe(true); + }); + + it('shows a line it cannot parse as it was stored, and says why', () => { + const [event] = renderRunEvents('{"type":"stage.entered",\n'); + expect(event?.text).toBe('{"type":"stage.entered",'); + expect(event?.parseError).toBeTruthy(); + }); +}); diff --git a/apps/web/__tests__/components/requests/run-stages_test.tsx b/apps/web/__tests__/components/requests/run-stages_test.tsx new file mode 100644 index 0000000000..afdd589ab6 --- /dev/null +++ b/apps/web/__tests__/components/requests/run-stages_test.tsx @@ -0,0 +1,71 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { RunStages } from '../../../src/components/requests/run-stages'; +import { renderInApp } from '../../render'; +import { encodeRun, streamFact, toNdjson } from '@floway-dev/pipeline/browser'; + +vi.mock('../../../src/components/ui/body-editor', () => ({ + default: ({ text, toolbarStart }: { text: string; toolbarStart: React.ReactNode }) =>
{toolbarStart}
{text}
, +})); + +const ndjson = [ + { type: 'object', fromObjectId: 1, nodes: [{ text: 'before' }, { text: 'after' }] }, + { type: 'stage.entered', stageId: 1, name: 'source', parentStageId: null, facts: { request: { $: 1 } } }, + { type: 'stage.entered', stageId: 2, name: 'target', parentStageId: 1, facts: { request: { $: 2 } } }, + { type: 'stage.leaved', stageId: 2, facts: { response: 'answer' } }, +].map(event => JSON.stringify(event)).join('\n'); + +describe('run stages', () => { + it('selects a stage and restores its actual request values', async () => { + renderInApp(); + await waitFor(() => expect(screen.getByTestId('state').textContent).toContain('before')); + fireEvent.click(screen.getByRole('treeitem', { name: 'target #2' })); + await waitFor(() => expect(screen.getByTestId('state').textContent).toContain('after')); + }); + + it('compares the values on a descent and the folded response', async () => { + renderInApp(); + await screen.findByTestId('state'); + fireEvent.click(screen.getByRole('combobox', { name: 'Stage view' })); + fireEvent.click(screen.getByRole('option', { name: 'Request changes' })); + expect(screen.getByTestId('state').textContent).toContain('"before": "before"'); + expect(screen.getByTestId('state').textContent).toContain('"after": "after"'); + fireEvent.click(screen.getByRole('combobox', { name: 'Stage view' })); + fireEvent.click(screen.getByRole('option', { name: 'Response facts' })); + expect(screen.getByTestId('state').textContent).toContain('"response": "answer"'); + }); + + it('shows the failure separately from a child response', async () => { + renderInApp(); + await screen.findByTestId('state'); + fireEvent.click(screen.getByRole('combobox', { name: 'Stage view' })); + fireEvent.click(screen.getByRole('option', { name: 'Response facts' })); + expect(screen.getByText('This stage failed without returning facts.')).toBeTruthy(); + fireEvent.click(screen.getByRole('combobox', { name: 'Stage view' })); + fireEvent.click(screen.getByRole('option', { name: 'Stage error' })); + expect(screen.getByTestId('state').textContent).toContain('projection failed'); + }); + + it('shows decoded protocol frames and the completion of each referenced stream', async () => { + const events = toNdjson(encodeRun([ + { type: 'stage.entered', stageId: 1, name: 'emit', parentStageId: null, facts: { source: streamFact(1) } }, + { type: 'stage.leaved', stageId: 1, facts: { source: streamFact(1), client: streamFact(2) } }, + { type: 'stream.frame', streamId: 1, frames: [{ type: 'event', event: { type: 'text', value: 'upstream content' } }] }, + { type: 'stream.end', streamId: 1 }, + { type: 'stream.frame', streamId: 2, frames: [{ type: 'event', event: { type: 'citation', source: 'https://citation.test' } }] }, + ])); + renderInApp(); + await screen.findByTestId('state'); + fireEvent.click(screen.getByRole('combobox', { name: 'Stage view' })); + fireEvent.click(screen.getByRole('option', { name: 'Protocol frames' })); + expect(screen.getByText('Recording complete')).toBeTruthy(); + expect(screen.getByText(/upstream content/)).toBeTruthy(); + fireEvent.click(screen.getByRole('combobox', { name: 'Protocol stream' })); + fireEvent.click(screen.getByRole('option', { name: '#2' })); + expect(screen.getByText('Recording incomplete')).toBeTruthy(); + expect(screen.getByText(/https:\/\/citation.test/)).toBeTruthy(); + expect(screen.queryByText(/upstream content/)).toBeNull(); + }); + +}); diff --git a/apps/web/__tests__/components/requests/ttft_test.tsx b/apps/web/__tests__/components/requests/ttft_test.tsx index bc9ee31b48..40457deb06 100644 --- a/apps/web/__tests__/components/requests/ttft_test.tsx +++ b/apps/web/__tests__/components/requests/ttft_test.tsx @@ -7,7 +7,8 @@ import type { ApiKey } from '../../../src/api/types'; import { RequestDetailPanel } from '../../../src/components/requests/detail'; import { RequestListPanel } from '../../../src/components/requests/list'; import { renderInApp } from '../../render'; -import type { DumpMetadata, DumpRecord } from '@floway-dev/gateway/browser'; +import type { DumpMetadata } from '@floway-dev/dump/browser'; +import type { DumpEdgeRecord } from '@floway-dev/gateway/browser'; vi.mock('../../../src/components/ui/body-editor', () => ({ default: ({ text, toolbarStart }: { text: string; toolbarStart?: ReactNode }) => ( @@ -46,7 +47,8 @@ const makeRecordMeta = (id: string, ttftMs: number | null): DumpMetadata => ({ upstream: null, }); -const makeFullRecord = (id: string, ttftMs: number | null): DumpRecord => ({ +const makeFullRecord = (id: string, ttftMs: number | null): DumpEdgeRecord => ({ + shape: 'edge', meta: makeRecordMeta(id, ttftMs), request: { method: 'POST', path: '/v1/chat/completions', headers: [], body: { encoding: 'utf8', data: '{}' } }, response: { status: 200, headers: [], body: { type: 'stream', events: [] } }, diff --git a/apps/web/package.json b/apps/web/package.json index 44a945ed7d..e6c73d5824 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,6 +11,8 @@ }, "dependencies": { "@floway-dev/agent-setup": "workspace:*", + "@floway-dev/dump": "workspace:*", + "@floway-dev/pipeline": "workspace:*", "@floway-dev/protocols": "workspace:*", "@floway-dev/provider": "workspace:*", "@floway-dev/provider-claude-code": "workspace:*", @@ -52,6 +54,7 @@ "zustand": "^5.0.14" }, "devDependencies": { + "@floway-dev/dump": "workspace:*", "@floway-dev/gateway": "workspace:*", "@react-router/dev": "8.3.0", "@testing-library/react": "^16.3.0", diff --git a/apps/web/src/components/requests/detail.tsx b/apps/web/src/components/requests/detail.tsx index 15680101de..49baa975fb 100644 --- a/apps/web/src/components/requests/detail.tsx +++ b/apps/web/src/components/requests/detail.tsx @@ -2,10 +2,13 @@ import { ArrowDownloadRegular, EyeOffRegular, EyeRegular, FlashRegular, InfoRegu import { lazy, Suspense, useMemo, useState } from 'react'; import { contentTypeOf, renderBody } from './body-render'; -import { EventList } from './events'; +import { EventList, RenderedEventList } from './events'; import { downloadRecords } from './export'; import { errorLabel, requestSeverity } from './format'; import { isSensitiveHeader, REDACTED_HEADER_VALUE, redactHeaderPairs, redactHeaderValue } from './header-redact'; +import { redactRunHeaders } from './run-redact'; +import { renderRunEvents } from './run-render'; +import { RunStages } from './run-stages'; import { collectKindFromTargetApi, detectCollectKind, type CollectedStream } from './stream-render'; import { fluentComponents } from '../../fluent'; import { useTranslation } from '../../i18n/translation'; @@ -19,7 +22,8 @@ import { OutcomeMessageBar } from '../ui/outcome-message-bar'; import { PANEL_BAND_CLASS } from '../ui/panel'; import { TooltipIconButton } from '../ui/tooltip-icon-button'; import { copyOutcomeIcon, useCopyLabel, useCopyToClipboard } from '../ui/use-copy-to-clipboard'; -import type { DumpRecord, DumpResponseBody } from '@floway-dev/gateway/browser'; +import type { DumpMetadata } from '@floway-dev/dump/browser'; +import type { DumpRecord, DumpResponseBody, DumpRunRecord, DumpEdgeRecord } from '@floway-dev/gateway/browser'; const BodyEditor = lazy(() => import('../ui/body-editor')); const { Button, DialogActions, DialogTitle, Option, Spinner, Text, Tooltip } = fluentComponents; @@ -66,12 +70,12 @@ export function RequestDetailPanel({ collected, upstreamCollected, error, record if (!shown.recordId) return {t('dashboard.requests.selectPrompt')}; if (shown.error) return {shown.error}; if (!shown.record) return null; + if (shown.record.shape === 'run') return ; return ; } -function RecordDetail({ record, collected, upstreamCollected }: { record: DumpRecord; collected: CollectedStream | null; upstreamCollected: CollectedStream | null }) { +function RecordDetail({ record, collected, upstreamCollected }: { record: DumpEdgeRecord; collected: CollectedStream | null; upstreamCollected: CollectedStream | null }) { const { t } = useTranslation(); - const locale = useLocale(); const [source, setSource] = useState('response'); const [view, setView] = useState('collected'); const [detailsOpen, setDetailsOpen] = useState(false); @@ -133,18 +137,7 @@ function RecordDetail({ record, collected, upstreamCollected }: { record: DumpRe {Object.entries(labels).map(([value, label]) => )} {status ?? t('dashboard.requests.noStatus')} - - - - - {record.meta.ttftMs != null && ( - - - - - )} + @@ -168,3 +161,48 @@ function RecordDetail({ record, collected, upstreamCollected }: { record: DumpRe ; } + +function RecordTiming({ meta }: { meta: DumpMetadata }) { + const { t } = useTranslation(); + const locale = useLocale(); + return <> + + + + + {meta.ttftMs != null && ( + + + + + )} + ; +} + +function RunRecordDetail({ record }: { record: DumpRunRecord }) { + const { t } = useTranslation(); + const [view, setView] = useState('stages'); + const redacted = useMemo(() => redactRunHeaders(record.events), [record.events]); + const count = useMemo(() => redacted.split('\n').filter(Boolean).length, [redacted]); + const events = useMemo(() => view === 'events' ? renderRunEvents(redacted).map(event => ({ + event: `${event.type} ${event.subject ?? ''}`.trim(), text: event.text, parseError: event.parseError, + })) : [], [redacted, view]); + const failure = errorLabel(record.meta.error); + return
+
+ setView(data.optionValue!)}> + + + + {record.meta.status ?? t('dashboard.requests.noStatus')} + + } label={t('dashboard.requests.exportRecord')} onClick={() => downloadRecords([record])} /> +
+ {failure && {failure}} +
+ {view === 'stages' ? : {t('dashboard.requests.events', { count })}} emptyText={t('dashboard.requests.noRunEvents')} />} +
+
; +} diff --git a/apps/web/src/components/requests/events.tsx b/apps/web/src/components/requests/events.tsx index 03b8213337..d0d1f2945a 100644 --- a/apps/web/src/components/requests/events.tsx +++ b/apps/web/src/components/requests/events.tsx @@ -16,7 +16,8 @@ import type { DumpStreamEvent } from '@floway-dev/gateway/browser'; const { Button, Menu, MenuItem, MenuItemCheckbox, MenuList, MenuPopover, MenuTrigger, Text, mergeClasses } = fluentComponents; -type IndexedEvent = RenderedStreamEvent & { index: number }; +export type RenderedEvent = Omit & { timestamp?: number }; +type IndexedEvent = RenderedEvent & { index: number }; interface EventRowProps { events: IndexedEvent[]; collapsed: Set; @@ -40,7 +41,7 @@ function EventRow({ index, style, ariaAttributes, events, collapsed, toggle, wra language={event.parseError || event.text === '[DONE]' ? 'plain' : 'json'} header={
- +{event.timestamp.toFixed(event.timestamp < 1 ? 3 : 0)}ms + {event.timestamp !== undefined && +{event.timestamp.toFixed(event.timestamp < 1 ? 3 : 0)}ms} {event.parseError && {t('dashboard.requests.eventParseError')}}
} /> @@ -48,6 +49,12 @@ function EventRow({ index, style, ariaAttributes, events, collapsed, toggle, wra } export function EventList({ events, kind, toolbarStart }: { events: DumpStreamEvent[]; kind: CollectKind | null; toolbarStart: ReactNode }) { + const rendered = useMemo(() => renderStreamEvents(kind, events), [events, kind]); + const copyText = useMemo(() => streamEventsCopyText(kind, events), [events, kind]); + return ; +} + +export function RenderedEventList({ events, copyText, toolbarStart, emptyText }: { events: RenderedEvent[]; copyText: string; toolbarStart: ReactNode; emptyText?: string }) { const { t } = useTranslation(); const [query, setQuery] = useState(''); const [searchOpen, setSearchOpen] = useState(false); @@ -58,7 +65,7 @@ export function EventList({ events, kind, toolbarStart }: { events: DumpStreamEv const { hostProps } = useScrollAreaHost({ axes: 'vertical', noTabIndex: true, viewport: list?.element ?? null }); const { copy, outcomeFor } = useCopyToClipboard(); const copyLabel = useCopyLabel(); - const rendered = useMemo(() => renderStreamEvents(kind, events).map((event, index) => ({ ...event, index })), [events, kind]); + const rendered = useMemo(() => events.map((event, index) => ({ ...event, index })), [events]); const filtered = useMemo(() => { const needle = query.trim().toLocaleLowerCase(); return needle ? rendered.filter(event => `${event.index + 1} ${event.event ?? ''} ${event.text}`.toLocaleLowerCase().includes(needle)) : rendered; @@ -80,7 +87,7 @@ export function EventList({ events, kind, toolbarStart }: { events: DumpStreamEv
{toolbarStart}
} label={t('dashboard.requests.findEvents')} onClick={() => { setSearchOpen(true); requestAnimationFrame(() => inputRef.current?.focus()); }} /> - copy(streamEventsCopyText(kind, events))} /> + copy(copyText)} /> setWrap(data.checkedItems.includes('on'))}>
} - {filtered.length === 0 ? {t('dashboard.requests.noEventMatches')} :
+ {filtered.length === 0 ? {query.trim() ? t('dashboard.requests.noEventMatches') : emptyText ?? t('dashboard.requests.noEventMatches')} :
{ + if (record.shape === 'run') return { ...record, events: redactRunHeaders(record.events) }; const capture = record.capture === undefined ? undefined : { ...record.capture, exchanges: record.capture.exchanges.map(exchange => ({ diff --git a/apps/web/src/components/requests/format.ts b/apps/web/src/components/requests/format.ts index 4f9e8dc5eb..99c677b59c 100644 --- a/apps/web/src/components/requests/format.ts +++ b/apps/web/src/components/requests/format.ts @@ -1,4 +1,4 @@ -import type { DumpErrorMeta, DumpMetadata } from '@floway-dev/gateway/browser'; +import type { DumpErrorMeta, DumpMetadata } from '@floway-dev/dump/browser'; export type RequestSeverity = 'success' | 'warning' | 'error'; diff --git a/apps/web/src/components/requests/list.tsx b/apps/web/src/components/requests/list.tsx index d46a2a5a6e..07ad42ee5c 100644 --- a/apps/web/src/components/requests/list.tsx +++ b/apps/web/src/components/requests/list.tsx @@ -31,7 +31,7 @@ import { useRouteAddress } from '../ui/route-link'; import { useScrollAreaHost } from '../ui/scroll-area'; import { TruncationTooltip } from '../ui/truncation-tooltip'; import { ProviderBadge } from '../upstreams/provider-badge'; -import type { DumpMetadata } from '@floway-dev/gateway/browser'; +import type { DumpMetadata } from '@floway-dev/dump/browser'; const { Option, Text, Tooltip, makeStyles, mergeClasses } = fluentComponents; const ROW_HEIGHT = 84; diff --git a/apps/web/src/components/requests/run-model.ts b/apps/web/src/components/requests/run-model.ts new file mode 100644 index 0000000000..bdceb084c9 --- /dev/null +++ b/apps/web/src/components/requests/run-model.ts @@ -0,0 +1,164 @@ +import { createRunReader, type DumpEvent } from '@floway-dev/pipeline/browser'; + +type Stored = Extract['nodes'][number]; +type FactState = Readonly>; + +export interface RunStage { + readonly id: number; + readonly name: string; + readonly parentId: number | null; + readonly children: RunStage[]; + readonly request: FactState; + response: FactState | null; + failure: FactState | null; + readonly logs: Extract[]; +} + +export interface RunStream { + readonly id: number; + readonly frames: Stored[]; + ended: boolean; +} + +export interface ValueChange { + readonly path: readonly (string | number)[]; + readonly kind: 'added' | 'removed' | 'changed'; + readonly before?: unknown; + readonly after?: unknown; +} + +const reference = (value: Stored): number | null => + typeof value === 'object' && value !== null && !Array.isArray(value) && '$' in value + ? value.$ as number : null; + +const decodeKey = (key: string): string => key.startsWith('$$') ? key.slice(1) : key; + +export const readRun = (ndjson: string) => { + const read = createRunReader(); + const stages = new Map(); + const outcomes = new Map(); + const roots: RunStage[] = []; + const streams = new Map(); + const stream = (id: number): RunStream => { + let recorded = streams.get(id); + if (recorded === undefined) { + recorded = { id, frames: [], ended: false }; + streams.set(id, recorded); + } + return recorded; + }; + const stage = (id: number): RunStage => { + const found = stages.get(id); + if (found === undefined) throw new Error(`Run references missing stage ${id}`); + return found; + }; + const node = (id: number): Stored => { + const stored = read.node(id); + return outcomes.has(id) ? { $deferred: outcomes.get(id)! } : stored; + }; + for (const line of ndjson.split('\n').filter(Boolean)) { + const event = JSON.parse(line) as DumpEvent; + switch (event.type) { + case 'object': + read(event); + break; + case 'stage.entered': { + const parent = event.parentStageId === null ? null : stage(event.parentStageId); + const request = event.facts ?? parent?.request; + if (request === undefined) throw new Error(`Run root stage ${event.stageId} has no request facts`); + const entered: RunStage = { + id: event.stageId, name: event.name, parentId: event.parentStageId, + children: [], logs: [], request, response: null, failure: null, + }; + stages.set(entered.id, entered); + (parent === null ? roots : parent.children).push(entered); + break; + } + case 'stage.leaved': stage(event.stageId).response = event.facts; break; + case 'stage.failed': stage(event.stageId).failure = { error: event.error }; break; + case 'stage.log': stage(event.stageId).logs.push(event); break; + case 'stream.frame': stream(event.streamId).frames.push(...event.frames); break; + case 'stream.end': stream(event.streamId).ended = true; break; + case 'deferred.settled': { + const id = reference(event.deferred); + if (id === null) throw new Error('Run deferred settlement has no object identity'); + outcomes.set(id, event.outcome); + break; + } + } + } + // Folded exits inherit the last descent's returned state, including repeated next(). + for (const current of [...stages.values()].reverse()) { + if (current.failure === null && current.response === null && current.children.length > 0) { + current.response = current.children.at(-1)!.response; + } + } + + const value = (stored: Stored, ancestors: ReadonlySet = new Set()): unknown => { + const id = reference(stored); + if (id !== null) { + if (ancestors.has(id)) return { $cycle: id }; + return value(node(id), new Set([...ancestors, id])); + } + if (Array.isArray(stored)) return stored.map(child => value(child, ancestors)); + if (typeof stored !== 'object' || stored === null) return stored; + return Object.fromEntries(Object.entries(stored).map(([key, child]) => [decodeKey(key), value(child, ancestors)])); + }; + const state = (facts: FactState): unknown => value(facts); + const referencedStreams = (...states: FactState[]): RunStream[] => { + const ids = new Set(); + const seen = new Set(); + const visit = (stored: Stored): void => { + const id = reference(stored); + if (id !== null) { + if (seen.has(id)) return; + seen.add(id); + visit(node(id)); + } else if (Array.isArray(stored)) stored.forEach(visit); + else if (typeof stored === 'object' && stored !== null) { + if ('$stream' in stored) ids.add(stored.$stream as number); + else Object.values(stored).forEach(visit); + } + }; + states.forEach(visit); + return [...ids].map(stream); + }; + + const diff = (before: FactState, after: FactState): ValueChange[] => { + const changes: ValueChange[] = []; + const compare = (left: Stored, right: Stored, path: readonly (string | number)[], pairs: ReadonlySet = new Set()): void => { + if (left === right) return; + const leftId = reference(left); + const rightId = reference(right); + if (leftId !== null && rightId !== null) { + if (leftId === rightId) return; + const pair = `${leftId}:${rightId}`; + if (pairs.has(pair)) return; + pairs = new Set([...pairs, pair]); + } + if (leftId !== null || rightId !== null) { + compare(leftId === null ? left : node(leftId), rightId === null ? right : node(rightId), path, pairs); + return; + } + const leftArray = Array.isArray(left); + const rightArray = Array.isArray(right); + if (typeof left === 'object' && left !== null && typeof right === 'object' && right !== null && leftArray === rightArray) { + const leftEntries = Object.entries(left); + const rightEntries = Object.entries(right); + const leftObject = Object.fromEntries(leftEntries) as Record; + const rightObject = Object.fromEntries(rightEntries) as Record; + for (const key of new Set([...leftEntries.map(([key]) => key), ...rightEntries.map(([key]) => key)])) { + const nextPath = [...path, leftArray ? Number(key) : decodeKey(key)]; + if (!(key in leftObject)) changes.push({ path: nextPath, kind: 'added', after: value(rightObject[key]!) }); + else if (!(key in rightObject)) changes.push({ path: nextPath, kind: 'removed', before: value(leftObject[key]!) }); + else compare(leftObject[key]!, rightObject[key]!, nextPath, pairs); + } + return; + } + changes.push({ path, kind: 'changed', before: value(left), after: value(right) }); + }; + compare(before, after, []); + return changes; + }; + return { roots, stages: [...stages.values()], state, diff, referencedStreams, frameValue: value }; +}; diff --git a/apps/web/src/components/requests/run-redact.ts b/apps/web/src/components/requests/run-redact.ts new file mode 100644 index 0000000000..198a2326e5 --- /dev/null +++ b/apps/web/src/components/requests/run-redact.ts @@ -0,0 +1,63 @@ +import { isSensitiveHeader, REDACTED_HEADER_VALUE, redactHeaderValue } from './header-redact'; +import type { DumpEvent } from '@floway-dev/pipeline/browser'; + +// Header values can be shared string nodes. Redacting only the tuple would +// leave the original credential in the object event that defined that node. +export const redactRunHeaders = (ndjson: string): string => { + const events = ndjson.split('\n').filter(Boolean).map(line => JSON.parse(line) as DumpEvent); + const nodes = new Map(); + for (const event of events) { + if (event.type !== 'object') continue; + for (let index = 0; index < event.nodes.length; index++) { + nodes.set(event.fromObjectId + index, { values: event.nodes as unknown[], index }); + } + } + const reference = (value: unknown): number | null => + typeof value === 'object' && value !== null && '$' in value && typeof value.$ === 'number' ? value.$ : null; + const node = (id: number) => { + const slot = nodes.get(id); + if (slot === undefined) throw new Error(`Run export references missing object ${id}`); + return slot; + }; + const resolve = (value: unknown): unknown => { + const id = reference(value); + if (id === null) return value; + const slot = node(id); + return slot.values[slot.index]; + }; + const redactedPairs = new Set(); + const redactedStrings = new Set(); + for (const event of events) { + if (event.type !== 'stage.entered' && event.type !== 'stage.leaved') continue; + for (const [key, value] of Object.entries(event.facts ?? {})) { + if (!key.endsWith('.http.headers')) continue; + const headers = resolve(value); + if (!Array.isArray(headers)) throw new Error(`Run export has invalid headers at ${key}`); + for (const entry of headers) { + const pair = resolve(entry); + if (!Array.isArray(pair) || pair.length !== 2) throw new Error(`Run export has invalid header at ${key}`); + if (redactedPairs.has(pair)) continue; + const name = resolve(pair[0]); + if (typeof name !== 'string') throw new Error(`Run export has invalid header name at ${key}`); + if (!isSensitiveHeader(name)) continue; + redactedPairs.add(pair); + const id = reference(pair[1]); + if (id !== null && redactedStrings.has(id)) continue; + const secret = resolve(pair[1]); + if (typeof secret !== 'string') { + if (typeof secret === 'object' && secret !== null && '$secret' in secret) continue; + throw new Error(`Run export has invalid header value at ${key}`); + } + if (secret === REDACTED_HEADER_VALUE) continue; + const redacted = redactHeaderValue(secret); + if (id === null) pair[1] = redacted; + else { + const slot = node(id); + slot.values[slot.index] = redacted; + redactedStrings.add(id); + } + } + } + } + return events.map(event => `${JSON.stringify(event)}\n`).join(''); +}; diff --git a/apps/web/src/components/requests/run-render.ts b/apps/web/src/components/requests/run-render.ts new file mode 100644 index 0000000000..ce9ab6bcc1 --- /dev/null +++ b/apps/web/src/components/requests/run-render.ts @@ -0,0 +1,46 @@ +import { errorMessage } from '../../lib/error-message'; +import type { DumpEvent } from '@floway-dev/pipeline/browser'; + +// A pipelined turn is recorded as its whole run: one NDJSON line per event, in +// the order the run emitted them. A line is also one SSE `data:` payload, so +// what this reads out of a stored record is what a live observer would be handed +// frame by frame. + +export interface RenderedRunEvent { + /** The event's own kind — `stage.entered`, `object`, `stream.frame`, … */ + type: string; + /** Which stage, object or stream it is about. Ids, not prose. */ + subject: string | null; + text: string; + parseError: string | null; +} + +const subjectOf = (event: DumpEvent): string | null => { + switch (event.type) { + case 'stage.entered': return event.name; + case 'stage.leaved': + case 'stage.failed': return `#${event.stageId}`; + case 'stage.log': return event.level; + case 'object': return `#${event.fromObjectId}`; + case 'stream.frame': + case 'stream.end': return `#${event.streamId}`; + case 'deferred.settled': return 'deferred'; + } +}; + +// A line the gateway wrote and this cannot parse is shown as it was stored, with +// the failure named: a record that renders as nothing would read as an empty run. +export const renderRunEvents = (ndjson: string): RenderedRunEvent[] => + ndjson.split('\n').filter(line => line.length > 0).map(line => { + try { + const event = JSON.parse(line) as DumpEvent; + return { + type: event.type, + subject: subjectOf(event), + text: JSON.stringify(event, null, 2), + parseError: null, + }; + } catch (error) { + return { type: '', subject: null, text: line, parseError: errorMessage(error) }; + } + }); diff --git a/apps/web/src/components/requests/run-stages.tsx b/apps/web/src/components/requests/run-stages.tsx new file mode 100644 index 0000000000..c124fc1073 --- /dev/null +++ b/apps/web/src/components/requests/run-stages.tsx @@ -0,0 +1,76 @@ +import { lazy, Suspense, useMemo, useState } from 'react'; + +import { RenderedEventList } from './events'; +import { readRun, type RunStage } from './run-model'; +import { fluentComponents } from '../../fluent'; +import { useTranslation } from '../../i18n/translation'; +import { EmptyStateLine } from '../ui/empty-state'; +import { Dropdown } from '../ui/fluent-form-controls'; +import { ScrollArea } from '../ui/scroll-area'; + +const BodyEditor = lazy(() => import('../ui/body-editor')); +const { Option, Spinner, Text, Tree, TreeItem, TreeItemLayout } = fluentComponents; + +const StageBranch = ({ stage, select }: { stage: RunStage; select: (id: number) => void }) => 0 ? 'branch' : 'leaf'} onFocus={event => { if (event.target === event.currentTarget) select(stage.id); }}> + {stage.name} #{stage.id} + {stage.children.length > 0 && {stage.children.map(child => )}} +; + +export const RunStages = ({ ndjson }: { ndjson: string }) => { + const { t } = useTranslation(); + const model = useMemo(() => readRun(ndjson), [ndjson]); + const [selected, setSelected] = useState(model.roots[0]?.id ?? null); + const [view, setView] = useState('request'); + const [descent, setDescent] = useState(0); + const [streamIndex, setStreamIndex] = useState(0); + const labels = { + request: t('dashboard.requests.requestFacts'), response: t('dashboard.requests.responseFacts'), + down: t('dashboard.requests.requestChanges'), up: t('dashboard.requests.responseChanges'), + logs: t('dashboard.requests.stageLogs'), error: t('dashboard.requests.stageError'), + frames: t('dashboard.requests.protocolFrames'), + }; + const current = model.stages.find(stage => stage.id === selected); + if (current === undefined) return {t('dashboard.requests.noRunEvents')}; + const child = current.children[descent]; + const streams = view === 'frames' ? model.referencedStreams(current.request, ...current.response === null ? [] : [current.response]) : []; + const stream = streams[streamIndex]; + const frames = stream?.frames.map(stored => { + const frame = model.frameValue(stored) as { type: string; event?: unknown }; + const event = frame.type === 'event' && typeof frame.event === 'object' && frame.event !== null + && 'type' in frame.event && typeof frame.event.type === 'string' ? frame.event.type : frame.type; + return { event, text: JSON.stringify(frame, null, 2), parseError: null }; + }) ?? []; + const text = view === 'frames' || view === 'logs' ? null : view === 'error' ? current.failure === null ? null : model.state(current.failure) + : view === 'request' ? model.state(current.request) + : view === 'response' ? current.response === null ? null : model.state(current.response) + : view === 'down' ? child === undefined ? [] : model.diff(current.request, child.request) + : child?.response === null || child === undefined || current.response === null ? [] : model.diff(child.response, current.response); + const select = (id: number) => { setSelected(id); setDescent(0); setStreamIndex(0); }; + const toolbar =
+ setView(data.optionValue!)}> + {Object.entries(labels).map(([value, label]) => )} + + {view === 'frames' && stream !== undefined && <> setStreamIndex(Number(data.optionValue))}> + {streams.map((recorded, index) => )} + {t(stream.ended ? 'dashboard.requests.recordingComplete' : 'dashboard.requests.recordingIncomplete')}} + {(view === 'down' || view === 'up') && current.children.length > 0 && setDescent(Number(data.optionValue))}> + {current.children.map((stage, index) => )} + } +
; + return
+ + stage.id)} onNavigation={(_, data) => { + if (data.type === 'Click') select(Number(data.value)); + }}> + {model.roots.map(stage => )} + + +
+ {view === 'frames' ? frame.text).join('\n')} toolbarStart={toolbar} emptyText={t(stream === undefined ? 'dashboard.requests.noProtocolStreams' : 'dashboard.requests.noProtocolFrames')} /> : view === 'response' && current.failure !== null ?
{toolbar}{t('dashboard.requests.stageFailed')}
: view === 'logs' ? ({ event: log.level, text: JSON.stringify({ ...log, ...(log.fields === undefined ? {} : { fields: model.state(log.fields) }) }, null, 2), parseError: null }))} + copyText={current.logs.map(log => JSON.stringify({ ...log, ...(log.fields === undefined ? {} : { fields: model.state(log.fields) }) })).join('\n')} + toolbarStart={toolbar} + /> : }>} +
+
; +}; diff --git a/apps/web/src/components/requests/use-dump-subscription.ts b/apps/web/src/components/requests/use-dump-subscription.ts index fdcebc89ba..efbdcb7bbd 100644 --- a/apps/web/src/components/requests/use-dump-subscription.ts +++ b/apps/web/src/components/requests/use-dump-subscription.ts @@ -5,7 +5,7 @@ import { api, callApi } from '../../api/client'; import { getSessionToken } from '../../auth/session'; import { useTranslation } from '../../i18n/translation'; import { errorMessage } from '../../lib/error-message'; -import type { DumpMetadata } from '@floway-dev/gateway/browser'; +import type { DumpMetadata } from '@floway-dev/dump/browser'; const PAGE_LIMIT = 100; @@ -74,8 +74,9 @@ export const useDumpSubscription = (keyId: string | null, initialRecords: DumpMe const snapshot = (JSON.parse((raw as MessageEvent).data) as { records: DumpMetadata[] }).records; setRecords(current => { const ids = new Set(snapshot.map(record => record.id)); - const oldest = snapshot.at(-1)?.id; - const tail = oldest ? current.filter(record => !ids.has(record.id) && record.id < oldest) : []; + const oldest = snapshot.at(-1); + const tail = oldest ? current.filter(record => !ids.has(record.id) + && (record.completedAt < oldest.completedAt || (record.completedAt === oldest.completedAt && record.id < oldest.id))) : []; const next = [...snapshot, ...tail]; seenRef.current = new Set(next.map(record => record.id)); return next; diff --git a/apps/web/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts index 7d0efabf68..570861410e 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -1165,6 +1165,24 @@ const en = { noKeysDescription: 'Enable request dump retention on an API key to start capturing requests.', apiKeysLink: 'API Keys', requestBody: 'Request body', + run: 'Run', + stages: 'Stages', + requestFacts: 'Request facts', + responseFacts: 'Response facts', + requestChanges: 'Request changes', + responseChanges: 'Response changes', + stageLogs: 'Stage logs', + stageError: 'Stage error', + stageFailed: 'This stage failed without returning facts.', + protocolFrames: 'Protocol frames', + protocolStream: 'Protocol stream', + noProtocolStreams: 'No protocol streams in this stage.', + noProtocolFrames: 'No protocol frames recorded.', + recordingComplete: 'Recording complete', + recordingIncomplete: 'Recording incomplete', + stageView: 'Stage view', + descent: 'Descent', + noRunEvents: 'This run recorded no events.', responseBody: 'Response body', emptyBody: 'Empty body', truncatedStream: 'No terminal marker was recorded. The assembled content is shown; completeness is unknown.', diff --git a/apps/web/src/i18n/locales/zh-Hans.ts b/apps/web/src/i18n/locales/zh-Hans.ts index 9d29228dd4..1ff2484531 100644 --- a/apps/web/src/i18n/locales/zh-Hans.ts +++ b/apps/web/src/i18n/locales/zh-Hans.ts @@ -1118,6 +1118,24 @@ const zhHansCN = { noKeysDescription: '在 API 密钥上启用请求转储保留后,即可开始捕获请求。', apiKeysLink: 'API 密钥', requestBody: '请求体', + run: '运行过程', + stages: 'Stages', + requestFacts: '请求 facts', + responseFacts: '响应 facts', + requestChanges: '请求变化', + responseChanges: '响应变化', + stageLogs: 'Stage 日志', + stageError: 'Stage 错误', + stageFailed: '此 stage 失败,未返回 facts。', + protocolFrames: '协议帧', + protocolStream: '协议流', + noProtocolStreams: '此 stage 没有协议流。', + noProtocolFrames: '没有记录到协议帧。', + recordingComplete: '记录完整', + recordingIncomplete: '记录不完整', + stageView: 'Stage 视图', + descent: '分支', + noRunEvents: '这次运行没有记录到任何事件。', responseBody: '响应体', emptyBody: '响应体为空', truncatedStream: '记录中没有结束标记。下面仍展示已解析内容,完整性未知。', diff --git a/apps/web/src/routes/dashboard-monitor-requests.tsx b/apps/web/src/routes/dashboard-monitor-requests.tsx index cb17b8962b..7b88ba6093 100644 --- a/apps/web/src/routes/dashboard-monitor-requests.tsx +++ b/apps/web/src/routes/dashboard-monitor-requests.tsx @@ -25,7 +25,8 @@ import { fluentComponents } from '../fluent'; import { dashboardWorkspaceHandle } from '../lib/dashboard-route-handle'; import { useEntryRewrite } from '../lib/page-navigation'; import { useMediaQuery } from '../lib/use-media-query'; -import type { DumpMetadata, DumpRecord } from '@floway-dev/gateway/browser'; +import type { DumpMetadata } from '@floway-dev/dump/browser'; +import type { DumpRecord } from '@floway-dev/gateway/browser'; export const handle = dashboardWorkspaceHandle; @@ -67,13 +68,15 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs): Promise ]); const record = recordResult?.data ?? null; const collectKind = record ? detectCollectKind(record.meta.path) : null; - const streamEvents = record?.response.body.type === 'stream' ? record.response.body.events : []; + // Only an edge-shaped record carries a captured frame log to collect; a run + // records its stream inside its own events and the detail panel reads it there. + const streamEvents = record?.shape === 'edge' && record.response.body.type === 'stream' ? record.response.body.events : []; const collected = collectKind && streamEvents.length ? await collectStream(collectKind, streamEvents) : null; // The pre-translation upstream view: dispatched by `meta.targetApi` (the // target protocol) rather than `meta.path` (the source protocol). Only // translated turns carry an upstream body. const upstreamCollectKind = record?.meta.targetApi ? collectKindFromTargetApi(record.meta.targetApi) : null; - const upstreamStreamEvents = record?.response.upstream?.body.type === 'stream' ? record.response.upstream.body.events : []; + const upstreamStreamEvents = record?.shape === 'edge' && record.response.upstream?.body.type === 'stream' ? record.response.upstream.body.events : []; const upstreamCollected = upstreamCollectKind && upstreamStreamEvents.length ? await collectStream(upstreamCollectKind, upstreamStreamEvents) : null; return { collected, diff --git a/packages/dump/__tests__/redact_test.ts b/packages/dump/__tests__/redact_test.ts new file mode 100644 index 0000000000..6c87304d33 --- /dev/null +++ b/packages/dump/__tests__/redact_test.ts @@ -0,0 +1,43 @@ +import { expect, test } from 'vitest'; + +import { REDACTED_HEADER_VALUE, redactRunCredentials } from '../src/index.ts'; +import { createRunReader, encodeRun, move, secret, toNdjson, type DumpEvent, type Event } from '@floway-dev/pipeline'; + +const exchange = { + type: 'response', + status: 200, + statusText: 'OK', + headers: [['Set-Cookie', 'session=operator'], ['x-request-id', 'trace']], + body: null, +}; + +const run = (): Event[] => [ + { + type: 'stage.entered', + stageId: 1, + name: 'call', + parentStageId: null, + facts: move({ + 'ingress.http.headers': [['authorization', 'Bearer client-key']], + 'request.http.headers': [['authorization', secret('Bearer operator-token')], ['x-api-key', 'operator-fixed-key'], ['accept', 'application/json']], + }), + }, + { type: 'stage.leaved', stageId: 1, facts: move({ 'response.http.exchange': exchange, 'response.http.headers': exchange.headers }) }, +]; + +const read = (ndjson: string) => { + const reader = createRunReader(); + return ndjson.split('\n').filter(Boolean).map(line => reader(JSON.parse(line) as DumpEvent)).flatMap(event => event === null || !('facts' in event) ? [] : [event.facts]); +}; + +test('upstream credential headers are redacted in every node that holds them', () => { + const redacted = redactRunCredentials(toNdjson(encodeRun(run()))); + const [entered, leaved] = read(redacted); + expect(entered!['ingress.http.headers']).toEqual([['authorization', 'Bearer client-key']]); + const upstream = entered!['request.http.headers'] as unknown[][]; + expect(upstream[1]).toEqual(['x-api-key', REDACTED_HEADER_VALUE]); + expect(upstream[2]).toEqual(['accept', 'application/json']); + expect((leaved!['response.http.exchange'] as typeof exchange).headers).toEqual([['Set-Cookie', REDACTED_HEADER_VALUE], ['x-request-id', 'trace']]); + expect(leaved!['response.http.headers']).toEqual([['Set-Cookie', REDACTED_HEADER_VALUE], ['x-request-id', 'trace']]); + expect(redacted).not.toContain('operator'); +}); diff --git a/packages/dump/package.json b/packages/dump/package.json index 13bcc5cf1b..2c71a68b62 100644 --- a/packages/dump/package.json +++ b/packages/dump/package.json @@ -18,6 +18,9 @@ "./codec": { "import": "./src/codec.ts", "types": "./src/codec.ts" + }, + "./browser": { + "types": "./src/browser.ts" } }, "scripts": { diff --git a/packages/dump/src/browser.ts b/packages/dump/src/browser.ts new file mode 100644 index 0000000000..7803565bf4 --- /dev/null +++ b/packages/dump/src/browser.ts @@ -0,0 +1,4 @@ +// Use `export type *` for types: these exports are erased at runtime. +// This dump entrypoint permits type exports only. + +export type * from './types.ts'; diff --git a/packages/dump/src/index.ts b/packages/dump/src/index.ts index 6893b349b1..ee1714cf09 100644 --- a/packages/dump/src/index.ts +++ b/packages/dump/src/index.ts @@ -4,3 +4,4 @@ export * from './codec.ts'; export * from './recorder.ts'; export * from './stream.ts'; export * from './wire.ts'; +export * from './redact.ts'; diff --git a/packages/dump/src/redact.ts b/packages/dump/src/redact.ts new file mode 100644 index 0000000000..54ba0a2644 --- /dev/null +++ b/packages/dump/src/redact.ts @@ -0,0 +1,86 @@ +import type { DumpEvent } from '@floway-dev/pipeline'; + +const SENSITIVE_HEADERS = new Set([ + 'api-key', + 'authorization', + 'cookie', + 'proxy-authorization', + 'set-cookie', + 'x-api-key', + 'x-goog-api-key', +]); + +export const REDACTED_HEADER_VALUE = '[redacted]'; + +export const isSensitiveHeader = (name: string): boolean => SENSITIVE_HEADERS.has(name.toLowerCase()); + +// The owner of a key may read the dump, but the upstream credentials in it +// belong to an operator who granted Model.Use, not to that key owner. +export const redactSensitiveHeaders = (headers: ReadonlyArray): Array<[string, string]> => + headers.map(([name, value]) => [name, isSensitiveHeader(name) ? REDACTED_HEADER_VALUE : value]); + +/** Header lists a run records about its upstream exchanges. The client's own request headers + * are the key owner's, so `ingress.http.headers` keeps them. */ +const upstreamHeaderLists = (key: string, value: unknown, resolve: (value: unknown) => unknown): unknown[] => { + if (key === 'ingress.http.headers') return []; + if (key.endsWith('.http.headers')) return [value]; + if (key !== 'response.http.exchange') return []; + const exchange = resolve(value); + if (typeof exchange !== 'object' || exchange === null || !('headers' in exchange)) return []; + return [exchange.headers]; +}; + +/** + * The run's recorded events with every upstream credential header replaced by its redacted + * form. A value a stage marked secret is already recorded redacted; this covers the plain + * strings an upstream sent back or an operator configured as a fixed header. Header strings are + * interned, so a value is rewritten in the object event that defined it — rewriting the tuple + * alone would leave the credential in that node. + */ +export const redactRunCredentials = (ndjson: string): string => { + const events = ndjson.split('\n').filter(line => line.length > 0).map(line => JSON.parse(line) as DumpEvent); + const nodes = new Map(); + for (const event of events) { + if (event.type !== 'object') continue; + event.nodes.forEach((_, index) => nodes.set(event.fromObjectId + index, { values: event.nodes as unknown[], index })); + } + const reference = (value: unknown): number | null => + typeof value === 'object' && value !== null && '$' in value && typeof value.$ === 'number' ? value.$ : null; + const slot = (id: number) => { + const found = nodes.get(id); + if (found === undefined) throw new Error(`Run events reference missing object ${id}`); + return found; + }; + const resolve = (value: unknown): unknown => { + const id = reference(value); + if (id === null) return value; + const found = slot(id); + return found.values[found.index]; + }; + const redactValue = (pair: unknown[]): void => { + const id = reference(pair[1]); + if (id === null) { + if (typeof pair[1] === 'string') pair[1] = REDACTED_HEADER_VALUE; + return; + } + const found = slot(id); + if (typeof found.values[found.index] === 'string') found.values[found.index] = REDACTED_HEADER_VALUE; + }; + for (const event of events) { + if (event.type !== 'stage.entered' && event.type !== 'stage.leaved') continue; + for (const [key, value] of Object.entries(event.facts ?? {})) { + for (const list of upstreamHeaderLists(key, value, resolve)) { + const headers = resolve(list); + if (!Array.isArray(headers)) throw new Error(`Run events record invalid headers at ${key}`); + for (const entry of headers) { + const pair = resolve(entry); + if (!Array.isArray(pair) || pair.length !== 2) throw new Error(`Run events record an invalid header at ${key}`); + const name = resolve(pair[0]); + if (typeof name !== 'string') throw new Error(`Run events record an invalid header name at ${key}`); + if (isSensitiveHeader(name)) redactValue(pair); + } + } + } + } + return events.map(event => `${JSON.stringify(event)}\n`).join(''); +}; diff --git a/packages/dump/src/wire.ts b/packages/dump/src/wire.ts index 023ac20ce1..608a65e30a 100644 --- a/packages/dump/src/wire.ts +++ b/packages/dump/src/wire.ts @@ -1,6 +1,7 @@ +import { redactRunCredentials } from './redact.ts'; import type { DumpRecord, StoredDumpRecord } from './types.ts'; export const dumpRecordToWire = (record: StoredDumpRecord): DumpRecord => ({ meta: record.meta, - events: new TextDecoder('utf-8', { fatal: true }).decode(record.events), + events: redactRunCredentials(new TextDecoder('utf-8', { fatal: true }).decode(record.events)), }); diff --git a/packages/gateway/__tests__/control-plane/dump_test.ts b/packages/gateway/__tests__/control-plane/dump_test.ts index 6bc9dd9f0d..babe7fb711 100644 --- a/packages/gateway/__tests__/control-plane/dump_test.ts +++ b/packages/gateway/__tests__/control-plane/dump_test.ts @@ -2,9 +2,10 @@ import { test } from 'vitest'; import { initDumpBroker, initDumpStore } from '../../src/dump/registry.ts'; import type { DumpStore } from '../../src/dump/store-contract.ts'; -import type { DumpMetadata, DumpRecord, StoredDumpRecord } from '../../src/dump/types.ts'; -import { fakeMeta as baseFakeMeta, fakeRecord as baseFakeRecord, installDumpStubs } from '../dump/test-fixtures.ts'; +import type { DumpRecord, StoredDumpRecord } from '../../src/dump/types.ts'; +import { fakeMeta as baseFakeMeta, fakeRecord as baseFakeRecord, fakeRunRecord, installDumpStubs } from '../dump/test-fixtures.ts'; import { requestApp, setupAppTest } from '../test-utils/app.ts'; +import type { DumpMetadata } from '@floway-dev/dump/types'; import { assertEquals, assertExists } from '@floway-dev/test-utils'; const fakeMeta = (id: string, completedAt: number): DumpMetadata => @@ -80,6 +81,41 @@ test('GET /api/dump/keys/:keyId/records/:recordId returns the rehydrated record' assertEquals(body.meta.id, '01HZZ0000000000000000000XX'); }); +// One list, both shapes — the metadata is common, so the dashboard's list says +// nothing about which mechanism served a turn — and a detail fetch hands each +// one back as what it is. +test('GET /api/dump/keys/:keyId/records serves a run record beside an edge one', async () => { + const { repo, apiKey } = await setupAppTest(); + await repo.apiKeys.save({ ...apiKey, dumpRetentionSeconds: 3600 }); + const stubs = installDumpStubs(initDumpStore, initDumpBroker); + stubs.seed(apiKey.id, fakeRecord('01HZZ0000000000000000EDGE', 1000)); + stubs.seed(apiKey.id, fakeRunRecord( + [ + { type: 'stage.entered', stageId: 1, name: 'serve', parentStageId: null, facts: { 'request.payload': { model: 'm' } } }, + { type: 'stage.leaved', stageId: 1, facts: { 'response.http.status': 200 } }, + ], + { id: '01HZZ00000000000000000RUN', completedAt: 2000, startedAt: 1999 }, + )); + + const listed = await requestApp(`/api/dump/keys/${apiKey.id}/records`, { headers: { 'x-api-key': apiKey.key } }); + assertEquals(listed.status, 200); + const { records } = await listed.json() as { records: DumpMetadata[] }; + assertEquals(records.map(meta => meta.id), ['01HZZ00000000000000000RUN', '01HZZ0000000000000000EDGE']); + + const run = await requestApp(`/api/dump/keys/${apiKey.id}/records/01HZZ00000000000000000RUN`, { headers: { 'x-api-key': apiKey.key } }); + const runBody = await run.json() as DumpRecord; + if (runBody.shape !== 'run') throw new Error(`expected the run shape, got ${runBody.shape}`); + // The object space rides with the events that reference it, so the `object` + // event carrying the payload arrives before the entry that points at it. + assertEquals(runBody.events.trimEnd().split('\n').map(line => (JSON.parse(line) as { type: string }).type), + ['object', 'stage.entered', 'stage.leaved']); + + const edge = await requestApp(`/api/dump/keys/${apiKey.id}/records/01HZZ0000000000000000EDGE`, { headers: { 'x-api-key': apiKey.key } }); + const edgeBody = await edge.json() as DumpRecord; + if (edgeBody.shape !== 'edge') throw new Error(`expected the edge shape, got ${edgeBody.shape}`); + assertEquals(edgeBody.request.method, 'POST'); +}); + test('GET /api/dump/keys/:keyId/records/:recordId 404s on unknown id', async () => { const { repo, apiKey } = await setupAppTest(); await repo.apiKeys.save({ ...apiKey, dumpRetentionSeconds: 3600 }); diff --git a/packages/gateway/__tests__/data-plane/alpha-search/routes_test.ts b/packages/gateway/__tests__/data-plane/alpha-search/routes_test.ts index b4290cf03a..e30917658b 100644 --- a/packages/gateway/__tests__/data-plane/alpha-search/routes_test.ts +++ b/packages/gateway/__tests__/data-plane/alpha-search/routes_test.ts @@ -4,10 +4,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { mountAlphaSearchRoutes } from '../../../src/data-plane/alpha-search/routes.ts'; import { resolveConfiguredWebSearchProvider } from '../../../src/data-plane/tools/web-search/provider.ts'; import type { WebSearchConfig, WebSearchFetchPageRequest, WebSearchFetchPageResult, WebSearchProvider, WebSearchProviderRequest, WebSearchProviderResult } from '../../../src/data-plane/tools/web-search/types.ts'; +import { initDumpBroker, initDumpStore } from '../../../src/dump/registry.ts'; import { type AuthVars, authMiddleware } from '../../../src/middleware/auth.ts'; import { internalErrorResponse } from '../../../src/middleware/internal-error-response.ts'; +import { eventsOf, installDumpStubs, runRecordOf } from '../../dump/test-fixtures.ts'; import { saveUpstreamForTest } from '../../repo/upstreams.ts'; -import { buildCustomUpstreamRecord, setupAppTest, warmModelsForTest } from '../../test-utils/app.ts'; +import { buildCustomUpstreamRecord, flushAsyncWork, setupAppTest, warmModelsForTest } from '../../test-utils/app.ts'; +import { createRunReader, type DumpEvent } from '@floway-dev/pipeline'; +import { decodeForgivingBase64 } from '@floway-dev/protocols/common'; import { withMockedFetch } from '@floway-dev/test-utils'; // Real provider construction (`createTavilyWebSearchProvider` etc.) hits the @@ -436,6 +440,35 @@ describe('/alpha/search data plane', () => { }); }); + describe('the run it is served by', () => { + it('records the whole run, stage by stage', async () => { + const { apiKey, repo } = await setupAppTest({ webSearchConfig: TAVILY_CONFIG }); + await repo.apiKeys.save({ ...apiKey, dumpRetentionSeconds: 3600 }); + const stub = makeStubProvider(); + mockResolveConfigured.mockReturnValue({ type: 'enabled', provider: 'tavily', impl: stub.provider }); + const dumpStubs = installDumpStubs(initDumpStore, initDumpBroker); + + const response = await postSearch(buildAlphaSearchApp(), apiKey.key, { commands: { search_query: [{ q: 'Floway' }] } }); + expect(response.status).toBe(200); + const wire = await response.text(); + await flushAsyncWork(); + + expect(dumpStubs.stored).toHaveLength(1); + const record = runRecordOf(dumpStubs.stored[0]?.record); + expect(record.meta.path).toBe(SEARCH_PATH); + expect(record.meta.status).toBe(200); + const entered = eventsOf(record) + .filter(event => event.type === 'stage.entered') + .map(event => event.name); + expect(entered).toEqual(['writeSettlement', 'serializeClientJson', 'emitAlphaSearch', 'parseSearchOperations', 'executeSearchOperations']); + const read = createRunReader(); + const decoded = eventsOf(record).map(event => read(event as unknown as DumpEvent)); + const bytes = decoded.find(event => event?.facts && 'response.http.jsonBody' in event.facts)?.facts?.['response.http.jsonBody'] as { bytes: string } | undefined; + expect(bytes).toBeDefined(); + expect(new TextDecoder().decode(decodeForgivingBase64(bytes!.bytes))).toBe(wire); + }); + }); + describe('provider not configured', () => { it('surfaces disabled search as in-band output text (contract-shaped 200)', async () => { const { apiKey, repo } = await setupAppTest(); diff --git a/packages/gateway/__tests__/data-plane/chat/anthropic-messages/attempt_test.ts b/packages/gateway/__tests__/data-plane/chat/anthropic-messages/attempt_test.ts index 916089d1c1..5e46cc3c5e 100644 --- a/packages/gateway/__tests__/data-plane/chat/anthropic-messages/attempt_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/anthropic-messages/attempt_test.ts @@ -70,7 +70,7 @@ const makeCandidate = (overrides: { return { provider: { upstreamId: upstream, kind: 'custom', name: upstream, inboundHeaderAllowlist: [], - disabledPublicModelIds: [], modelPrefix: null, modelsCache: null, instance: provider, + disabledPublicModelIds: [], modelPrefix: null, modelsCache: null, pipelines: {}, instance: provider, }, model: stubInternalModel({ endpoints, diff --git a/packages/gateway/__tests__/data-plane/chat/anthropic-messages/http_test.ts b/packages/gateway/__tests__/data-plane/chat/anthropic-messages/http_test.ts index d7c7f576b5..7a80b32d9c 100644 --- a/packages/gateway/__tests__/data-plane/chat/anthropic-messages/http_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/anthropic-messages/http_test.ts @@ -116,7 +116,7 @@ const makeCandidate = (overrides: { return { provider: { upstreamId: upstream, kind: 'custom', name: upstream, inboundHeaderAllowlist: [], - disabledPublicModelIds: [], modelPrefix: null, modelsCache: null, instance: provider, + disabledPublicModelIds: [], modelPrefix: null, modelsCache: null, pipelines: {}, instance: provider, }, model: stubInternalModel(overrides.endpoints ? { endpoints: overrides.endpoints } : {}, upstream), fetcher: directFetcher, diff --git a/packages/gateway/__tests__/data-plane/chat/anthropic-messages/serve_test.ts b/packages/gateway/__tests__/data-plane/chat/anthropic-messages/serve_test.ts index 5689b6a7ef..75191e4f5a 100644 --- a/packages/gateway/__tests__/data-plane/chat/anthropic-messages/serve_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/anthropic-messages/serve_test.ts @@ -143,7 +143,7 @@ const makeCandidate = (overrides: { return { provider: { upstreamId: upstream, kind, name: upstream, inboundHeaderAllowlist: [], - disabledPublicModelIds: [], modelPrefix: null, modelsCache: null, instance: provider, + disabledPublicModelIds: [], modelPrefix: null, modelsCache: null, pipelines: {}, instance: provider, }, model: stubInternalModel({ id: modelId, @@ -706,7 +706,7 @@ test('alias whose targets have no kind-matching binding surfaces as the regular // callback so the http.ts catch can build an internal-error result carrying // the correct upstream, and `recordFailedRequest` lands a row rather than // short-circuiting on missing telemetry. Passthrough's equivalent regression -// lives in passthrough-serve_test.ts (R3 fix 303c4e89). +// lived in passthrough-serve_test.ts, which went with that surface (R3 fix 303c4e89). test('mid-attempt throw stamps telemetry with the throwing candidate, not the previous one', async () => { installRepo(); const firstError = new Response(JSON.stringify({ error: { message: 'nope' } }), { diff --git a/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/attempt_test.ts b/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/attempt_test.ts index d4317a6f45..e57708ee5c 100644 --- a/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/attempt_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/attempt_test.ts @@ -89,7 +89,7 @@ const makeCandidate = (overrides: { return { provider: { upstreamId: upstream, kind: 'custom', name: upstream, inboundHeaderAllowlist: [], - disabledPublicModelIds: [], modelPrefix: null, modelsCache: null, instance: provider, + disabledPublicModelIds: [], modelPrefix: null, modelsCache: null, pipelines: {}, instance: provider, }, model: stubInternalModel(overrides.endpoints ? { endpoints: overrides.endpoints } : {}, upstream), fetcher: directFetcher, diff --git a/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/http_test.ts b/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/http_test.ts index 80873c9e83..ba7be16757 100644 --- a/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/http_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/http_test.ts @@ -120,7 +120,7 @@ const makeCandidate = (overrides: { return { provider: { upstreamId: upstream, kind: 'custom', name: upstream, inboundHeaderAllowlist: [], - disabledPublicModelIds: [], modelPrefix: null, modelsCache: null, instance: provider, + disabledPublicModelIds: [], modelPrefix: null, modelsCache: null, pipelines: {}, instance: provider, }, model: stubInternalModel({ endpoints }, upstream), fetcher: directFetcher, diff --git a/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/serve_test.ts b/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/serve_test.ts index 4f286e79d9..1c4748e7ee 100644 --- a/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/serve_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/serve_test.ts @@ -139,7 +139,7 @@ const makeCandidate = (overrides: { return { provider: { upstreamId: upstream, kind: 'custom', name: upstream, inboundHeaderAllowlist: [], - disabledPublicModelIds: [], modelPrefix: null, modelsCache: null, instance: provider, + disabledPublicModelIds: [], modelPrefix: null, modelsCache: null, pipelines: {}, instance: provider, }, model: stubInternalModel({ endpoints }, upstream), fetcher: directFetcher, diff --git a/packages/gateway/__tests__/data-plane/chat/openai-chat-completions/attempt_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-chat-completions/attempt_test.ts index 5ffc87a4c9..03d35d709a 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-chat-completions/attempt_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-chat-completions/attempt_test.ts @@ -78,7 +78,7 @@ const makeCandidate = (overrides: { return { provider: { upstreamId: upstream, kind: 'custom', name: upstream, inboundHeaderAllowlist: [], - disabledPublicModelIds: [], modelPrefix: null, modelsCache: null, instance: provider, + disabledPublicModelIds: [], modelPrefix: null, modelsCache: null, pipelines: {}, instance: provider, }, model: stubInternalModel({ endpoints, diff --git a/packages/gateway/__tests__/data-plane/chat/openai-chat-completions/http_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-chat-completions/http_test.ts index 80a48983b9..4ce3cbed0c 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-chat-completions/http_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-chat-completions/http_test.ts @@ -112,7 +112,7 @@ const makeCandidate = (overrides: { return { provider: { upstreamId: upstream, kind: 'custom', name: upstream, inboundHeaderAllowlist: [], - disabledPublicModelIds: [], modelPrefix: null, modelsCache: null, instance: provider, + disabledPublicModelIds: [], modelPrefix: null, modelsCache: null, pipelines: {}, instance: provider, }, model: stubInternalModel(overrides.endpoints ? { endpoints: overrides.endpoints } : {}, upstream), fetcher: directFetcher, diff --git a/packages/gateway/__tests__/data-plane/chat/openai-chat-completions/serve_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-chat-completions/serve_test.ts index c799f6d960..2fdc98684a 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-chat-completions/serve_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-chat-completions/serve_test.ts @@ -95,7 +95,7 @@ const makeCandidate = (overrides: { return { provider: { upstreamId: upstream, kind: 'custom', name: upstream, inboundHeaderAllowlist: [], - disabledPublicModelIds: [], modelPrefix: null, modelsCache: null, instance: provider, + disabledPublicModelIds: [], modelPrefix: null, modelsCache: null, pipelines: {}, instance: provider, }, model: stubInternalModel({ id: overrides.modelId ?? 'test-model', diff --git a/packages/gateway/__tests__/data-plane/chat/openai-responses/affinity/copilot-roundtrip_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-responses/affinity/copilot-roundtrip_test.ts index 15475891ff..2b06de0a7e 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-responses/affinity/copilot-roundtrip_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-responses/affinity/copilot-roundtrip_test.ts @@ -87,7 +87,7 @@ test('Copilot item-id and generic affinity trailers compose and unwrap in bounda inboundHeaderAllowlist: [], disabledPublicModelIds: [], modelPrefix: null, - modelsCache: null, + modelsCache: null, pipelines: {}, instance: stubProvider(), }, model: { id: 'gpt-test', endpoints: { openaiResponses: {} } }, diff --git a/packages/gateway/__tests__/data-plane/chat/openai-responses/attempt_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-responses/attempt_test.ts index 7ce6137d20..f56f976af6 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-responses/attempt_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-responses/attempt_test.ts @@ -69,7 +69,7 @@ const makeCandidate = ( inboundHeaderAllowlist: [], disabledPublicModelIds: [], modelPrefix: null, - modelsCache: null, + modelsCache: null, pipelines: {}, instance: provider, }, model: stubInternalModel({ @@ -313,7 +313,7 @@ test('generate defers the role rewrite until after translation to OpenAI Chat Co inboundHeaderAllowlist: [], disabledPublicModelIds: [], modelPrefix: null, - modelsCache: null, + modelsCache: null, pipelines: {}, instance: stubProvider({ callOpenAIChatCompletions }), }, model: stubInternalModel({ @@ -464,7 +464,7 @@ test('generate strips disallowed headers and states a remote image URL to Anthro const candidate: ModelCandidate = { provider: { upstreamId: 'up_test', kind: 'custom', name: 'up_test', inboundHeaderAllowlist: [], - disabledPublicModelIds: [], modelPrefix: null, modelsCache: null, instance: anthropicMessagesProvider, + disabledPublicModelIds: [], modelPrefix: null, modelsCache: null, pipelines: {}, instance: anthropicMessagesProvider, }, model: upstreamModel, fetcher: directFetcher, @@ -502,7 +502,7 @@ test('generate preserves order and downgrades a disallowed system turn across tr const candidate: ModelCandidate = { provider: { upstreamId: 'up_test', kind: 'claude-code', name: 'up_test', inboundHeaderAllowlist: [], - disabledPublicModelIds: [], modelPrefix: null, modelsCache: null, + disabledPublicModelIds: [], modelPrefix: null, modelsCache: null, pipelines: {}, instance: stubProvider({ callAnthropicMessages: async (_model, body): Promise> => { observedBody = body as Omit; @@ -771,7 +771,7 @@ test('generate lowers agent_message to a framed user message across translation const candidate: ModelCandidate = { provider: { upstreamId: 'up_test', kind: 'custom', name: 'up_test', inboundHeaderAllowlist: [], - disabledPublicModelIds: [], modelPrefix: null, modelsCache: null, + disabledPublicModelIds: [], modelPrefix: null, modelsCache: null, pipelines: {}, instance: stubProvider({ callAnthropicMessages: async (_model, body): Promise> => { observedBody = body as Omit; @@ -822,7 +822,7 @@ test('generate lowers agent_message to a framed user message across translation const candidate: ModelCandidate = { provider: { upstreamId: 'up_chat', kind: 'custom', name: 'up_chat', inboundHeaderAllowlist: [], - disabledPublicModelIds: [], modelPrefix: null, modelsCache: null, + disabledPublicModelIds: [], modelPrefix: null, modelsCache: null, pipelines: {}, instance: stubProvider({ callOpenAIChatCompletions: async (_model, body): Promise> => { observedBody = body; diff --git a/packages/gateway/__tests__/data-plane/chat/openai-responses/http_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-responses/http_test.ts index 25af406ab8..b474ffaa92 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-responses/http_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-responses/http_test.ts @@ -139,7 +139,7 @@ const makeCandidate = (overrides: { inboundHeaderAllowlist: [], disabledPublicModelIds: [], modelPrefix: null, - modelsCache: null, + modelsCache: null, pipelines: {}, instance: provider, }, model: stubInternalModel({ diff --git a/packages/gateway/__tests__/data-plane/chat/openai-responses/interceptors/server-tools/image-generation-integration_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-responses/interceptors/server-tools/image-generation-integration_test.ts index 5b9826cd07..bc1536d67f 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-responses/interceptors/server-tools/image-generation-integration_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-responses/interceptors/server-tools/image-generation-integration_test.ts @@ -398,7 +398,7 @@ test('an image generated in turn 1 is re-collected as an edit source in turn 2', assertEquals(request.images.length, 1); const image = request.images[0]; assert(image.type === 'upload'); - const bytes = await image.file.text(); + const bytes = new TextDecoder().decode(image.file.bytes); assertEquals(bytes, 'AAAA'); }); @@ -438,7 +438,7 @@ test('a prefetched remote edit source remains visible to orchestration and is re 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))); + assertEquals(new Uint8Array(image.file.bytes), Uint8Array.from(atob(REMOTE_PNG_B64), c => c.charCodeAt(0))); }); test('mask-only GIF edit transcodes one shared image and mask to WebP', async () => { @@ -468,8 +468,8 @@ test('mask-only GIF edit transcodes one shared image and mask to WebP', async () 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'); + assertEquals(new TextDecoder().decode(image.file.bytes), 'WEBP'); + assertEquals(new TextDecoder().decode(mask.file.bytes), 'WEBP'); }); test('identical GIF source and mask share one transcode', async () => { @@ -498,8 +498,8 @@ test('identical GIF source and mask share one transcode', async () => { 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'); + assertEquals(new TextDecoder().decode(image.file.bytes), 'WEBP'); + assertEquals(new TextDecoder().decode(request.mask.file.bytes), 'WEBP'); }); test('image transcoding failure becomes a terminal image tool failure', async () => { diff --git a/packages/gateway/__tests__/data-plane/chat/openai-responses/serve_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-responses/serve_test.ts index ff2ea91ef2..74283849dd 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-responses/serve_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-responses/serve_test.ts @@ -133,7 +133,7 @@ const makeCandidate = (overrides: { inboundHeaderAllowlist: [], disabledPublicModelIds: [], modelPrefix: null, - modelsCache: null, + modelsCache: null, pipelines: {}, instance: provider, }, // Default keeps stubInternalModel's three-endpoint map intact; tests that diff --git a/packages/gateway/__tests__/data-plane/chat/openai-responses/websocket_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-responses/websocket_test.ts index eef9808a16..079935623c 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-responses/websocket_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-responses/websocket_test.ts @@ -9,7 +9,7 @@ import { INTERRUPT_UPSTREAM_ABORT_WARNING_MS, KEEP_ALIVE_EVENT_TYPE } from '../. import { DOWNSTREAM_KEEP_ALIVE_INTERVAL_MS } from '../../../../src/data-plane/shared/sse.ts'; import { initDumpBroker, initDumpStore } from '../../../../src/dump/registry.ts'; import { initBackgroundSchedulerResolver } from '../../../../src/runtime/background.ts'; -import { installDumpStubs } from '../../../dump/test-fixtures.ts'; +import { edgeRecordOf, installDumpStubs } from '../../../dump/test-fixtures.ts'; import { saveUpstreamForTest } from '../../../repo/upstreams.ts'; import { FakeTime } from '../../../test-time.ts'; import { buildCodexUpstreamRecord, codexModels, copilotModels, flushAsyncWork, setupAppTest, sseResponse, sseOpenAIResponsesResponse, warmModelsForTest } from '../../../test-utils/app.ts'; @@ -265,9 +265,10 @@ test('OpenAI Responses WebSocket starts capturing on the next turn when dump ret const stored = dumps.stored[0]; assertExists(stored); assertEquals(stored.keyId, apiKey.id); - assertEquals(stored.record.request.method, 'WS'); - assertEquals(stored.record.request.path, '/v1/responses'); - assertEquals(JSON.parse(new TextDecoder().decode(stored.record.request.body)), { + const request = edgeRecordOf(stored.record).request; + assertEquals(request.method, 'WS'); + assertEquals(request.path, '/v1/responses'); + assertEquals(JSON.parse(new TextDecoder().decode(request.body)), { type: 'response.create', event_id: 'capture-after-enable', response: { diff --git a/packages/gateway/__tests__/data-plane/decision/serve_test.ts b/packages/gateway/__tests__/data-plane/decision/http_test.ts similarity index 96% rename from packages/gateway/__tests__/data-plane/decision/serve_test.ts rename to packages/gateway/__tests__/data-plane/decision/http_test.ts index c38cde5403..ffa6e16746 100644 --- a/packages/gateway/__tests__/data-plane/decision/serve_test.ts +++ b/packages/gateway/__tests__/data-plane/decision/http_test.ts @@ -305,7 +305,7 @@ test('/v1/decisions surfaces a cross-dialect upstream body it cannot parse', asy ); }); -test('/v1/systemone forwards an unparseable same-dialect body verbatim', async () => { +test('/v1/systemone answers an unreadable same-dialect body with its own error', async () => { const { apiKey, repo } = await setupAppTest(); await saveDecisionUpstream(repo, { protocol: 'typesafe-systemone' }); @@ -317,9 +317,12 @@ test('/v1/systemone forwards an unparseable same-dialect body verbatim', async ( headers: requestHeaders(apiKey.key), body: JSON.stringify(choiceRequest), }); - // Same dialect: the body needs no translation, so it passes through. - assertEquals(response.status, 200); - assertEquals(await response.text(), 'opaque upstream bytes'); + // The protocol requires JSON, so a body that is not JSON is not an answer the gateway + // can carry — even to a client on the upstream's own dialect. + assertEquals(response.status, 502); + assertEquals(await response.json(), { + error: { message: 'The upstream answered 200 with a body the decision protocol cannot read: opaque upstream bytes', type: 'api_error' }, + }); }, ); }); diff --git a/packages/gateway/__tests__/data-plane/openai-audio-pipeline_test.ts b/packages/gateway/__tests__/data-plane/openai-audio-pipeline_test.ts new file mode 100644 index 0000000000..124e6bc00f --- /dev/null +++ b/packages/gateway/__tests__/data-plane/openai-audio-pipeline_test.ts @@ -0,0 +1,188 @@ +// OpenAI Audio Transcriptions' pipeline, assembled. `compose` derives the entry contract and +// rejects an array that cannot work, so most of what this file establishes is established +// by the module importing at all — the assembly runs at load. What is worth writing down is +// the entry contract it derives, the keys it cannot see, and the one runtime property this +// family's code is shaped around that no declaration expresses. + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { openaiAudioTranscriptionServePipeline } from '../../src/data-plane/openai-audio/pipeline.ts'; +import { createCandidateRegistry } from '../../src/data-plane/pipeline/candidates.ts'; +import { enumerateModelCandidates } from '../../src/data-plane/providers/resolution.ts'; +import { initRepo } from '../../src/repo/index.ts'; +import { mockGatewayCtx } from '../test-utils/gateway-ctx.ts'; +import { stubProviderPipeline } from '../test-utils/provider-pipeline.ts'; +import { isOwned, move, run } from '@floway-dev/pipeline'; +import type { SseFrame } from '@floway-dev/protocols/common'; +import { directFetcher, type ModelCandidate, type ProviderCallResult } from '@floway-dev/provider'; +import { stubInternalModel, stubProvider, stubProviderModel } from '@floway-dev/test-utils'; + +vi.mock('../../src/data-plane/providers/resolution.ts', async importOriginal => ({ + ...(await importOriginal()), + enumerateModelCandidates: vi.fn(), +})); + +let live: readonly ModelCandidate[] = []; + +const candidate = (callOpenAIAudioTranscriptions: () => Promise): ModelCandidate => { + const endpoints = { openaiAudioTranscriptions: {} }; + return { + provider: { + upstreamId: 'up_a', kind: 'custom', name: 'up_a', inboundHeaderAllowlist: [], + disabledPublicModelIds: [], modelPrefix: null, modelsCache: null, + instance: stubProvider(), + pipelines: { openaiAudioTranscriptions: stubProviderPipeline('openaiAudioTranscriptions', callOpenAIAudioTranscriptions) }, + }, + model: stubInternalModel( + { id: 'whisper-1', kind: 'transcription', endpoints, providerModels: { up_a: stubProviderModel({ id: 'whisper-1', endpoints }) } }, + 'up_a', + ), + fetcher: directFetcher, + } as unknown as ModelCandidate; +}; + +const resolves = (candidates: readonly ModelCandidate[]): void => { + live = candidates; + vi.mocked(enumerateModelCandidates).mockResolvedValue({ candidates, sawModel: true, failedUpstreams: [] } as never); +}; + +const sse = (...events: readonly string[]): Response => + new Response(events.map(event => `data: ${event}\n\n`).join(''), { status: 200, headers: { 'content-type': 'text/event-stream' } }); + +const serve = async (responseFormat = 'json') => await run( + openaiAudioTranscriptionServePipeline, + move({ + 'ingress.openaiAudioTranscription.responseFormat': responseFormat, + 'ingress.http.headers': [] as readonly (readonly [string, string])[], + 'request.openaiAudioTranscription.form': [{ name: 'model', value: 'whisper-1' }], + 'serve.model': 'whisper-1', + }) as never, + { + gateway: mockGatewayCtx({ wantsStream: responseFormat === 'stream' }), + background: () => {}, + ...createCandidateRegistry(), + resolveAttempt: (selector: { readonly upstreamId: string }) => { + const found = live.find(c => c.provider.upstreamId === selector.upstreamId); + if (found === undefined) throw new Error(`no live candidate for ${selector.upstreamId}`); + return found; + }, + } as never, +); + +beforeEach(() => { + vi.mocked(enumerateModelCandidates).mockReset(); + initRepo({ + apiKeys: { update: async () => {} }, + usage: { record: async () => {} }, + performance: { recordNeutral: async () => {}, recordZeroOutputError: async () => {} }, + } as never); +}); + +describe('the OpenAI Audio Transcriptions pipeline', () => { + it('assembles, and asks its caller for what the descending stages need', () => { + expect([...openaiAudioTranscriptionServePipeline.entryNeeds].sort()).toEqual([ + 'ingress.http.headers', + 'ingress.openaiAudioTranscription.responseFormat', + 'request.openaiAudioTranscription.form', + 'serve.model', + ]); + }); + + it('names the entry key a caller did not bring, before any stage runs', async () => { + await expect(run(openaiAudioTranscriptionServePipeline, move({ 'serve.model': 'whisper-1' }) as never, {})) + .rejects.toThrow('run(openaiAudioTranscriptionServe): openaiAudioTranscriptionServe needs'); + }); + + // The streamed answer is a wrapper around its generator rather than the generator itself. + // That was once load-bearing: the runner detected ownership structurally, an async + // generator carries `Symbol.asyncDispose`, and the sweep would have called it — which for + // a generator is `return()`, cancelling the iteration rather than draining it. + // + // The runner now claims ownership through `own()` instead of detecting it, so a bare + // generator is safe in the record. The wrapper stays because it says which thing is the + // resource: the upstream body at `response.http.body`, and nothing else here. + it('renders a transcription the upstream answered with, as a 200', async () => { + resolves([candidate(async () => ({ + response: Response.json({ text: 'hello there' }), + modelKey: 'whisper-key', + } as ProviderCallResult))]); + + const { facts } = await serve(); + + expect(facts['response.http.status']).toBe(200); + expect(facts['response.openaiAudioTranscription.rendered']).toMatchObject({ text: 'hello there' }); + }); + + // A refusal reaches the client with the status the upstream gave it, and in the words the + // upstream used — the same statement every other family makes. + it('answers an upstream refusal with its own status and words', async () => { + resolves([candidate(async () => ({ + response: new Response(JSON.stringify({ error: { message: 'too large' } }), { + status: 413, headers: { 'content-type': 'application/json' }, + }), + modelKey: 'whisper-key', + } as ProviderCallResult))]); + + const { facts } = await serve(); + + expect(facts['response.http.status']).toBe(413); + expect(facts['response.openaiAudioTranscription.rendered']).toEqual({ error: { message: 'too large' } }); + }); + + // The reader stops at the terminal event. An upstream that holds the connection open past + // it would otherwise hold the client's stream open with it, which is what the replaced + // surface avoided by cancelling the moment the transcript was complete. + it('stops reading a stream at its terminal event', async () => { + const done = JSON.stringify({ type: 'transcript.text.done', text: 'hi', usage: { type: 'tokens', input_tokens: 3, output_tokens: 1 } }); + const after = JSON.stringify({ type: 'transcript.text.delta', delta: 'never read' }); + resolves([candidate(async () => ({ + response: sse(JSON.stringify({ type: 'transcript.text.delta', delta: 'hi' }), done, after), + modelKey: 'whisper-key', + } as ProviderCallResult))]); + + const { facts, drain } = await serve('json'); + const frames: SseFrame[] = []; + for await (const frame of facts['response.openaiAudioTranscription.rendered'] as AsyncIterable) frames.push(frame); + await drain(); + + expect(frames).toHaveLength(2); + expect(frames.map(frame => JSON.parse(frame.data) as { type: string }).map(event => event.type)) + .toEqual(['transcript.text.delta', 'transcript.text.done']); + }); + + it('preserves upstream SSE event labels and data spelling', async () => { + const delta = '{ "type": "transcript.text.delta", "delta": "hi", "vendor": true }'; + const extension = '{ "vendor_extension": "no type discriminator" }'; + const done = '{ "type": "transcript.text.done", "text": "hi", "usage": { "type": "tokens", "input_tokens": 3, "output_tokens": 1 } }'; + resolves([candidate(async () => ({ + response: new Response(`event: transcript.delta\ndata: ${delta}\n\nevent: vendor.extension\ndata: ${extension}\n\nevent: transcript.completed\ndata: ${done}\n\n`, { + headers: { 'content-type': 'text/event-stream' }, + }), + modelKey: 'whisper-key', + } as ProviderCallResult))]); + + const { facts, drain } = await serve(); + const frames: SseFrame[] = []; + for await (const frame of facts['response.openaiAudioTranscription.rendered'] as AsyncIterable) frames.push(frame); + await drain(); + + expect(frames).toEqual([ + { type: 'sse', event: 'transcript.delta', data: delta }, + { type: 'sse', event: 'vendor.extension', data: extension }, + { type: 'sse', event: 'transcript.completed', data: done }, + ]); + expect(await facts['response.openaiAudioTranscription.streamedOutcome']).toMatchObject({ + failed: false, + billable: [{ quantities: { input_tokens: '3', output_tokens: '1' } }], + }); + }); + + it('keeps the events view clear of what answers to release', () => { + // Neither the generator nor the view around it is a resource. Whether the host marks a + // generator disposable varies — Node 24 does, Node 22 does not — and the run's answer + // does not, because ownership is claimed rather than read off the value. + const generator = (async function* () { yield 1; })(); + expect(isOwned(generator)).toBe(false); + expect(isOwned({ [Symbol.asyncIterator]: () => generator })).toBe(false); + }); +}); diff --git a/packages/gateway/__tests__/data-plane/openai-audio/http_test.ts b/packages/gateway/__tests__/data-plane/openai-audio/http_test.ts index 0fea74628f..ec9e706c11 100644 --- a/packages/gateway/__tests__/data-plane/openai-audio/http_test.ts +++ b/packages/gateway/__tests__/data-plane/openai-audio/http_test.ts @@ -1,5 +1,7 @@ import { test, vi } from 'vitest'; +import { initDumpBroker, initDumpStore } from '../../../src/dump/registry.ts'; +import { eventsOf, installDumpStubs, runRecordOf } from '../../dump/test-fixtures.ts'; import type { InMemoryRepo } from '../../repo/memory.ts'; import { saveUpstreamForTest } from '../../repo/upstreams.ts'; import { flushAsyncWork, MOCKED_FETCH_EGRESS, requestAppWithWarmModels, setupAppTest } from '../../test-utils/app.ts'; @@ -152,6 +154,44 @@ test('/v1/audio/transcriptions forwards VTT verbatim and records request-only us assertEquals(usage.metrics, []); }); +// A subtitle document is carried, so every convention the upstream did not follow survives: +// the ordinal it started at, the line ending it chose, and the fraction digits it wrote. +// Reading the cues and writing them back would have normalized all three. +test('/v1/audio/transcriptions forwards SubRip with the numbering and line endings the upstream chose', async () => { + const { apiKey, repo } = await setupAppTest(); + await registerAudioModel(repo); + const document = '7\r\n00:00:00,0 --> 00:00:01,5\r\nhello\r\n'; + await withMockedFetch( + () => new Response(document, { headers: { 'content-type': 'application/x-subrip' } }), + async () => { + const response = await requestAppWithWarmModels('/v1/audio/transcriptions', { + method: 'POST', headers: { 'x-api-key': apiKey.key }, body: transcriptionForm([['response_format', 'srt']]), + }); + assertEquals(response.headers.get('content-type'), 'application/x-subrip'); + assertEquals(await response.text(), document); + }, + ); +}); + +// The document never becomes a string on the way through, so an upstream that answered under +// a charset nobody here asked about is forwarded rather than mangled: decoding these bytes as +// UTF-8 and encoding them again would replace both of them with U+FFFD. +test('/v1/audio/transcriptions forwards a document the gateway never decoded', async () => { + const { apiKey, repo } = await setupAppTest(); + await registerAudioModel(repo); + const latin1 = new Uint8Array([0x63, 0x61, 0x66, 0xE9, 0x20, 0xE0, 0x20, 0x31]); + await withMockedFetch( + () => new Response(latin1, { headers: { 'content-type': 'text/plain; charset=iso-8859-1' } }), + async () => { + const response = await requestAppWithWarmModels('/v1/audio/transcriptions', { + method: 'POST', headers: { 'x-api-key': apiKey.key }, body: transcriptionForm([['response_format', 'text']]), + }); + assertEquals(response.headers.get('content-type'), 'text/plain; charset=iso-8859-1'); + assertEquals(new Uint8Array(await response.arrayBuffer()), latin1); + }, + ); +}); + test('/v1/audio/transcriptions skips JSON parsing for text responses without warning', async () => { const { apiKey, repo } = await setupAppTest(); await registerAudioModel(repo); @@ -333,9 +373,11 @@ test('/v1/audio/transcriptions preserves token usage unpriced when the model is test('/v1/audio/transcriptions streams through transcript.text.done without adding Chat termination', async () => { const { apiKey, repo } = await setupAppTest(); + await repo.apiKeys.save({ ...apiKey, dumpRetentionSeconds: 3600 }); await registerAudioModel(repo, { entries: [{ rates: { input_audio_tokens: '0.000001', output_tokens: '0.000001' } }], }); + const dumpStubs = installDumpStubs(initDumpStore, initDumpBroker); await withMockedFetch( () => new Response([ 'data: {"type":"transcript.text.delta","delta":"hel"}', @@ -364,6 +406,16 @@ test('/v1/audio/transcriptions streams through transcript.text.done without addi const [performance] = await repo.performance.listAll(); assertEquals(performance.neutral, 1); assertEquals(performance.errorsNoOutput, 0); + + // This family's stream is bare protocol events rather than protocol frames, so the record is + // told how one becomes a frame — and until it was, a streamed transcription was recorded as + // stage boundaries with the transcript missing entirely. + const events = eventsOf(runRecordOf(dumpStubs.stored[0]?.record)); + const frames = events + .filter(event => event.type === 'stream.frame') + .flatMap(event => (event as unknown as { frames: readonly unknown[] }).frames); + assertEquals(frames.length, 2); + assertEquals(events.filter(event => event.type === 'stream.end'), [{ type: 'stream.end', streamId: 1 }]); }); test('/v1/audio/transcriptions preserves a terminal stream event with malformed usage', async () => { diff --git a/packages/gateway/__tests__/data-plane/openai-audio/usage_test.ts b/packages/gateway/__tests__/data-plane/openai-audio/usage_test.ts deleted file mode 100644 index d55bd38f6a..0000000000 --- a/packages/gateway/__tests__/data-plane/openai-audio/usage_test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { test } from 'vitest'; - -import { openaiAudioTranscriptionUsageMeasurement } from '../../../src/data-plane/openai-audio/usage.ts'; -import { assertEquals, assertThrows } from '@floway-dev/test-utils'; - -test('audio transcription usage preserves duration seconds as a base-unit metric', () => { - assertEquals(openaiAudioTranscriptionUsageMeasurement({ - usage: { type: 'duration', seconds: 91 }, - duration: 91.8, - }), { - quantities: { input_audio_seconds: '91' }, - pricingFacts: {}, - dumpTokenUsage: null, - }); -}); - -test('audio transcription usage reads Whisper verbose JSON duration', () => { - assertEquals(openaiAudioTranscriptionUsageMeasurement({ - task: 'transcribe', - duration: 91.8, - text: 'hello', - }), { - quantities: { input_audio_seconds: '91.8' }, - pricingFacts: {}, - dumpTokenUsage: null, - }); -}); - -test('audio transcription usage maps text and audio input token details to disjoint metrics', () => { - assertEquals(openaiAudioTranscriptionUsageMeasurement({ - usage: { - type: 'tokens', - input_tokens: 14, - input_token_details: { text_tokens: 4, audio_tokens: 10 }, - output_tokens: 45, - total_tokens: 59, - }, - }), { - quantities: { input_tokens: '4', input_audio_tokens: '10', output_tokens: '45' }, - pricingFacts: { inputTokens: 14 }, - dumpTokenUsage: { input: 14, output: 45 }, - }); -}); - -test('audio transcription usage keeps aggregate input tokens general when details are absent', () => { - assertEquals(openaiAudioTranscriptionUsageMeasurement({ - usage: { type: 'tokens', input_tokens: 14, output_tokens: 45, total_tokens: 59 }, - }).quantities, { input_tokens: '14', output_tokens: '45' }); -}); - -test('audio transcription usage accepts partial details and leaves unclassified input general', () => { - for (const [input_token_details, quantities] of [ - [{}, { input_tokens: '14', output_tokens: '45' }], - [{ text_tokens: 4 }, { input_tokens: '14', output_tokens: '45' }], - [{ audio_tokens: 10 }, { input_tokens: '4', input_audio_tokens: '10', output_tokens: '45' }], - ] as const) { - assertEquals(openaiAudioTranscriptionUsageMeasurement({ - usage: { type: 'tokens', input_tokens: 14, input_token_details, output_tokens: 45, total_tokens: 59 }, - }).quantities, quantities); - } -}); - -test('audio transcription usage without a recognized metric is request-only', () => { - for (const body of [ - { usage: { seconds: 10 } }, - { usage: { type: 'future_metric', samples: 10 } }, - ]) { - assertEquals(openaiAudioTranscriptionUsageMeasurement(body), { - quantities: {}, pricingFacts: {}, dumpTokenUsage: null, - }); - } -}); - -test('audio transcription usage rejects malformed declared metrics', () => { - for (const [body, message] of [ - [{ duration: '10' }, 'duration must be'], - [{ usage: null }, 'usage must be an object'], - [{ usage: 'tokens' }, 'usage must be an object'], - [{ usage: { type: 'duration' } }, 'duration usage.seconds'], - [{ usage: { type: 'duration', seconds: '10' } }, 'duration usage.seconds'], - [{ usage: { type: 'tokens', input_tokens: -1, output_tokens: 45, total_tokens: 44 } }, 'token usage.input_tokens'], - [{ usage: { type: 'tokens', input_tokens: 14, output_tokens: Number.NaN, total_tokens: 59 } }, 'token usage.output_tokens'], - [{ usage: { type: 'tokens', input_tokens: 14, output_tokens: 45, total_tokens: '59' } }, 'token usage.total_tokens'], - [{ usage: { type: 'tokens', input_tokens: 14, output_tokens: 45, total_tokens: 58 } }, 'total_tokens must equal'], - [{ usage: { type: 'tokens', input_tokens: 14, input_token_details: null, output_tokens: 45, total_tokens: 59 } }, 'input_token_details must be an object'], - [{ usage: { type: 'tokens', input_tokens: 14, input_token_details: { text_tokens: 4, audio_tokens: '10' }, output_tokens: 45, total_tokens: 59 } }, 'audio_tokens must be'], - [{ usage: { type: 'tokens', input_tokens: 14, input_token_details: { text_tokens: 6, audio_tokens: 9 }, output_tokens: 45, total_tokens: 59 } }, 'input_token_details must not exceed'], - ] as const) { - assertThrows(() => openaiAudioTranscriptionUsageMeasurement(body), Error, message); - } -}); diff --git a/packages/gateway/__tests__/data-plane/openai-completions-pipeline_test.ts b/packages/gateway/__tests__/data-plane/openai-completions-pipeline_test.ts new file mode 100644 index 0000000000..8c694df07c --- /dev/null +++ b/packages/gateway/__tests__/data-plane/openai-completions-pipeline_test.ts @@ -0,0 +1,424 @@ +// OpenAI Completions' pipeline, assembled and run. `compose` derives the entry contract and +// rejects an array that cannot work, so the assembly succeeding is itself most of what a test +// of the wiring would say — what is written down here is the entry contract, the two things the +// assembly cannot see, and one run of each shape the answer can take, because this is the +// first family whose answer can be a stream and none of that is visible in a declaration. + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { openaiCompletionsServePipeline } from '../../src/data-plane/openai-completions/pipeline.ts'; +import { createCandidateRegistry } from '../../src/data-plane/pipeline/candidates.ts'; +import { enumerateModelCandidates } from '../../src/data-plane/providers/resolution.ts'; +import { initRepo } from '../../src/repo/index.ts'; +import { mockGatewayCtx } from '../test-utils/gateway-ctx.ts'; +import { stubProviderPipeline } from '../test-utils/provider-pipeline.ts'; +import { compose, defineStage, isDeferred, move, run } from '@floway-dev/pipeline'; +import type { SseFrame } from '@floway-dev/protocols/common'; +import { directFetcher, type ModelCandidate, type ProviderCallResult, type UpstreamCallOptions, type ProviderRequest, type ProviderResponse, type ProviderOperationPayloads } from '@floway-dev/provider'; +import { stubInternalModel, stubProvider, stubProviderModel } from '@floway-dev/test-utils'; + +vi.mock('../../src/data-plane/providers/resolution.ts', async importOriginal => ({ + ...(await importOriginal()), + enumerateModelCandidates: vi.fn(), +})); + +/** The live candidates the resolver hands back. They never enter the record: a candidate + * carries the provider's instance, its fetcher and its models cache, and freezing those is + * what putting one in the record would do. What travels is the selector. */ +let live: readonly ModelCandidate[] = []; + +const resolves = (candidates: readonly ModelCandidate[]): void => { + live = candidates; + vi.mocked(enumerateModelCandidates).mockResolvedValue({ candidates, sawModel: true, failedUpstreams: [] }); +}; + +const resolveAttempt = (selector: { readonly upstreamId: string }): ModelCandidate => { + const found = live.find(candidate => candidate.provider.upstreamId === selector.upstreamId); + if (found === undefined) throw new Error(`no live candidate for ${selector.upstreamId}`); + return found; +}; + +const candidate = ( + upstream: string, + callOpenAICompletions: (model: unknown, body: unknown, signal: AbortSignal | undefined, opts: UpstreamCallOptions) => Promise, +): ModelCandidate => { + const endpoints = { openaiChatCompletions: {}, openaiCompletions: {} }; + return { + provider: { + upstreamId: upstream, kind: 'custom', name: upstream, inboundHeaderAllowlist: [], + disabledPublicModelIds: [], modelPrefix: null, modelsCache: null, + instance: stubProvider(), + pipelines: { openaiCompletions: stubProviderPipeline('openaiCompletions', callOpenAICompletions) }, + }, + model: stubInternalModel({ id: 'text-model', endpoints, providerModels: { [upstream]: stubProviderModel({ id: 'text-model', endpoints }) } }, upstream), + fetcher: directFetcher, + }; +}; + +const sse = (...events: readonly string[]): Response => + new Response(events.map(event => `data: ${event}\n\n`).join(''), { status: 200, headers: { 'content-type': 'text/event-stream' } }); + +const chunk = (text: string): string => + JSON.stringify({ id: 'cmpl_1', object: 'text_completion', created: 1, model: 'text-model', choices: [{ index: 0, text }] }); + +const usageChunk = JSON.stringify({ + id: 'cmpl_1', object: 'text_completion', created: 1, model: 'text-model', choices: [], + usage: { prompt_tokens: 5, completion_tokens: 7, total_tokens: 12 }, +}); + +// Settlement is part of every serve pipeline now, and it writes. A test that drives a whole +// pipeline therefore needs somewhere for the row to go — the point being that the write +// happens at all, which is what "a run that measured rather than generated still writes" +// means and what nothing was checking before. +const recorded: { usage: unknown[]; performance: unknown[] } = { usage: [], performance: [] }; + +beforeEach(() => { + recorded.usage = []; + recorded.performance = []; + initRepo({ + apiKeys: { update: async () => {} }, + usage: { record: async (row: unknown) => { recorded.usage.push(row); } }, + performance: { + recordNeutral: async (dims: unknown) => { recorded.performance.push(dims); }, + recordZeroOutputError: async (dims: unknown) => { recorded.performance.push(dims); }, + }, + } as never); +}); + +const serve = async (facts: Record) => await run( + openaiCompletionsServePipeline, + move(facts) as never, + { + gateway: mockGatewayCtx({ wantsStream: facts['ingress.openaiCompletions.wantsStream'] === true }), + background: () => {}, + ...createCandidateRegistry(), + resolveAttempt, + } as never, +); + +const entryFacts = (overrides: Record = {}) => ({ + 'ingress.openaiCompletions.wantsStream': true, + 'ingress.openaiCompletions.wantsUsageChunk': false, + 'ingress.http.headers': [], + 'request.openaiCompletions.payload': { model: 'text-model', prompt: 'hello', stream: true }, + 'serve.model': 'text-model', + ...overrides, +}); + +const collect = async (rendered: unknown): Promise => { + const frames: SseFrame[] = []; + for await (const frame of rendered as AsyncIterable) frames.push(frame); + return frames; +}; + +beforeEach(() => { vi.mocked(enumerateModelCandidates).mockReset(); }); + +describe('the OpenAI Completions pipeline', () => { + it('assembles, and asks its caller for what the descending stages need', () => { + expect([...openaiCompletionsServePipeline.entryNeeds].sort()).toEqual([ + 'ingress.http.headers', + 'ingress.openaiCompletions.wantsStream', + 'ingress.openaiCompletions.wantsUsageChunk', + 'request.openaiCompletions.payload', + 'serve.model', + ]); + }); + + it('renders the upstream frames as SSE, hiding the usage chunk the client did not ask for', async () => { + let sent: Record | undefined; + resolves([candidate('up_a', async (_model, body) => { + sent = body as Record; + return { response: sse(chunk('he'), chunk('llo'), usageChunk, '[DONE]'), modelKey: 'text-model-key' }; + })]); + + const { facts, drain } = await serve(entryFacts()); + + // The answer comes back before the drain runs, which is what lets a streaming family + // hand its stream on: the frames are still there to read. + expect(await collect(facts['response.openaiCompletions.rendered'])).toEqual([ + { type: 'sse', event: undefined, data: chunk('he') }, + { type: 'sse', event: undefined, data: chunk('llo') }, + { type: 'sse', event: undefined, data: '[DONE]' }, + ]); + // Metering is the gateway's, not the client's: the upstream is always asked for the + // chunk the client is not shown. + expect(sent).toMatchObject({ stream_options: { include_usage: true } }); + expect(sent).not.toHaveProperty('model'); + await drain(); + }); + + it('says the upstream was called and reported nothing until the frames run out', async () => { + resolves([candidate('up_a', async () => ({ response: sse(chunk('hi'), usageChunk, '[DONE]'), modelKey: 'text-model-key' }))]); + + const { facts, drain } = await serve(entryFacts()); + + // What is known when the ending stage hands up: an entity, and no quantities. The + // numbers arrive with the last chunk, which is after this run has answered. + expect(facts['response.usage.billable']).toEqual([ + { identity: { model: 'text-model', upstream: 'up_a', modelKey: 'text-model-key', pricing: null }, quantities: {} }, + ]); + // And the reading is declared as this run's own unfinished work rather than started and + // forgotten, which is what puts it in front of teardown: a family that hands up a reading + // that never settles is reported instead of silently never billing. + expect(isDeferred(facts['response.openaiCompletions.streamedUsage'])).toBe(true); + await collect(facts['response.openaiCompletions.rendered']); + expect(await facts['response.openaiCompletions.streamedUsage']).toMatchObject({ + failed: false, + billable: [ + { + identity: { model: 'text-model', upstream: 'up_a', modelKey: 'text-model-key', pricing: null }, + quantities: { input_tokens: '5', output_tokens: '7' }, + }, + ], + }); + await drain(); + }); + + it('shows the usage chunk to a client that asked for it', async () => { + resolves([candidate('up_a', async () => ({ response: sse(chunk('hi'), usageChunk, '[DONE]'), modelKey: 'text-model-key' }))]); + + const { facts, drain } = await serve(entryFacts({ 'ingress.openaiCompletions.wantsUsageChunk': true })); + + expect((await collect(facts['response.openaiCompletions.rendered'])).map(frame => frame.data)).toEqual([chunk('hi'), usageChunk, '[DONE]']); + await drain(); + }); + + it('serializes a non-streaming answer from the value it parsed, and bills what it read', async () => { + const body = { id: 'cmpl_1', object: 'text_completion', created: 1, model: 'text-model', choices: [{ index: 0, text: 'hi', finish_reason: 'stop' }], usage: { prompt_tokens: 5, completion_tokens: 7, total_tokens: 12 } }; + resolves([candidate('up_a', async () => ({ response: Response.json(body), modelKey: 'text-model-key' }))]); + + const { facts, drain } = await serve(entryFacts({ + 'ingress.openaiCompletions.wantsStream': false, + 'request.openaiCompletions.payload': { model: 'text-model', prompt: 'hello' }, + })); + + expect(facts['response.openaiCompletions.rendered']).toEqual(body); + expect(facts['response.openaiCompletions.streamedUsage']).toBeNull(); + expect(facts['response.usage.billable']).toEqual([ + { + identity: { model: 'text-model', upstream: 'up_a', modelKey: 'text-model-key', pricing: null }, + quantities: { input_tokens: '5', input_cache_read_tokens: '0', input_cache_write_tokens: '0', output_tokens: '7' }, + pricingFacts: { inputTokens: 5, serviceTier: null }, + }, + ]); + await drain(); + }); + + // Ownership is claimed, never detected, and until the claim was made the whole mechanism + // was inert: `failover` declared it consumes the body, `drain()` existed, and no family + // ever marked a body — so a losing attempt's connection stayed open and the winner's was + // never drained. This is the property, not the absence of the bug. + it('drains the losing attempt-s body at the fork, and the winner-s at the drain', async () => { + const drained: string[] = []; + const body = (label: string, chunks: readonly string[]): ReadableStream => { + let index = 0; + return new ReadableStream({ + pull: controller => { + if (index < chunks.length) { controller.enqueue(new TextEncoder().encode(chunks[index]!)); index += 1; return; } + drained.push(label); + controller.close(); + }, + }); + }; + resolves([ + candidate('up_a', async () => ({ + response: new Response(body('loser', []), { status: 429, headers: { 'content-type': 'application/json' } }), + modelKey: 'k', + })), + candidate('up_b', async () => ({ + response: new Response(body('winner', [`data: ${chunk('hi')}\n\n`, 'data: [DONE]\n\n']), { + status: 200, headers: { 'content-type': 'text/event-stream' }, + }), + modelKey: 'k', + })), + ]); + + const { facts, drain } = await serve(entryFacts()); + // The client's stream is still live: the run answered before anything was drained. + expect(facts['response.openaiCompletions.rendered']).toBeDefined(); + await drain(); + expect(drained).toContain('winner'); + }); + + it.each([false, true])('includes the provider retry observation for stream=%s', async streaming => { + const upstream = candidate('up_a', async () => ({ + response: streaming ? sse(chunk('hi'), usageChunk, '[DONE]') : Response.json({ + id: 'cmpl_1', choices: [], service_tier: 'flex', usage: { prompt_tokens: 0, completion_tokens: 0 }, + }), + modelKey: 'final-model', + })); + const terminal = upstream.provider.pipelines.openaiCompletions!; + upstream.provider.pipelines = { + openaiCompletions: compose('retryingProvider', [ + defineStage, ProviderRequest, ProviderResponse, ProviderResponse>({ + name: 'retryObservation', + through: { + request: { needs: [], consumes: [], provides: [] }, + response: { needs: [], consumes: [], provides: ['response.provider.previousCalls'] }, + }, + execute: async (facts, next) => ({ ...await next({ ...facts }), 'response.provider.previousCalls': move([{ modelKey: 'initial-model' }]) }), + }), + defineStage, ProviderRequest, ProviderResponse, ProviderResponse>({ + name: 'providerEntry', + into: { + request: { needs: [], consumes: [], provides: [] }, + response: { needs: [], consumes: [], provides: [] }, + }, + execute: async (facts, next) => ({ ...await next({ ...facts }, terminal) }), + }), + ]), + }; + resolves([upstream]); + const { facts, drain } = await serve(entryFacts({ 'ingress.openaiCompletions.wantsStream': streaming })); + if (streaming) await collect(facts['response.openaiCompletions.rendered']); + await drain(); + expect(recorded.usage).toMatchObject([ + { modelKey: 'initial-model', requests: 1, metrics: [] }, + { modelKey: 'final-model', requests: 1 }, + ]); + if (!streaming) { + expect(facts['response.usage.billable'][1]).toMatchObject({ + quantities: { input_tokens: '0', output_tokens: '0' }, + pricingFacts: { inputTokens: 0, serviceTier: 'flex' }, + }); + } + }); + + it('finishes metering after an early consumer return', async () => { + resolves([candidate('up_a', async () => ({ response: sse(chunk('hi'), usageChunk, '[DONE]'), modelKey: 'k' }))]); + const { facts, drain } = await serve(entryFacts()); + const reader = (facts['response.openaiCompletions.rendered'] as AsyncIterable)[Symbol.asyncIterator](); + expect((await reader.next()).done).toBe(false); + await reader.return!(); + await drain(); + expect((await facts['response.openaiCompletions.streamedUsage'])!.billable[0]!.quantities).toMatchObject({ input_tokens: '5', output_tokens: '7' }); + expect(recorded.usage).toHaveLength(1); + }); + + it('invalid streamed usage rejects its deferred reading and settles the proven call', async () => { + const invalidUsage = JSON.stringify({ + id: 'cmpl_1', choices: [], usage: { + prompt_tokens: 4, completion_tokens: 1, total_tokens: 5, + prompt_tokens_details: { cached_tokens: 8 }, + }, + }); + resolves([candidate('up_a', async () => ({ response: sse(chunk('hi'), invalidUsage, '[DONE]'), modelKey: 'k' }))]); + const work: Promise[] = []; + const { facts, drain } = await run(openaiCompletionsServePipeline, move(entryFacts()), { + gateway: mockGatewayCtx({ wantsStream: true }), + background: (promise: Promise) => { work.push(promise); void promise.catch(() => {}); }, + ...createCandidateRegistry(), resolveAttempt, + }); + await expect(collect(facts['response.openaiCompletions.rendered'])).rejects.toBeInstanceOf(RangeError); + await expect(drain()).rejects.toBeInstanceOf(RangeError); + await Promise.allSettled(work); + expect(recorded.usage).toMatchObject([{ modelKey: 'k', requests: 1, metrics: [] }]); + }); + + it('settles completed attempts when a later refusal body throws, preserving the original exception', async () => { + const original = new Error('response socket reset', { cause: new Error('connection closed') }); + resolves([ + candidate('up_a', async () => ({ response: Response.json({ error: 'retry' }, { status: 429 }), modelKey: 'first' })), + candidate('up_b', async () => ({ + response: new Response(new ReadableStream({ start: controller => { controller.error(original); } }), { status: 429 }), + modelKey: 'second', + })), + ]); + await expect(serve(entryFacts({ 'ingress.openaiCompletions.wantsStream': false }))).rejects.toBe(original); + expect(recorded.usage).toMatchObject([{ modelKey: 'first', requests: 1 }, { modelKey: 'second', requests: 1 }]); + expect(recorded.performance).toHaveLength(1); + }); + + it('settles each upstream observation across failed-over attempts', async () => { + resolves([ + candidate('up_a', async () => ({ response: Response.json({ error: 'nope' }, { status: 429 }), modelKey: 'k' })), + candidate('up_b', async () => ({ + response: Response.json({ id: 'c', choices: [], usage: { prompt_tokens: 7, completion_tokens: 2 } }), + modelKey: 'k', + })), + ]); + const { drain } = await serve(entryFacts({ + 'ingress.openaiCompletions.wantsStream': false, + 'request.openaiCompletions.payload': { model: 'text-model', prompt: 'hello' }, + })); + await drain(); + expect(recorded.usage).toHaveLength(2); + expect(recorded.performance).toHaveLength(1); + }); + + // A stream states its usage in the chunk that ends it, which is after the run has answered. + // Settling in the stage as well would write the row twice — once for the entity that had + // reported nothing, once for what the stream turned out to say — so the pipeline hands the + // numbers up as a promise and the epilogue is what writes them. + it('defers a stream-s settlement to the promise it hands up', async () => { + resolves([candidate('up_b', async () => ({ response: sse(chunk('hi'), usageChunk, '[DONE]'), modelKey: 'k' }))]); + + const { facts, drain } = await serve(entryFacts()); + expect(recorded.usage).toHaveLength(0); + await drain(); + expect(recorded.usage).toHaveLength(1); + const streamed = facts['response.openaiCompletions.streamedUsage']; + expect(streamed).not.toBeNull(); + const outcome = await streamed!; + expect(outcome.billable).toHaveLength(1); + expect(outcome.billable[0]!.quantities).toMatchObject({ input_tokens: '5', output_tokens: '7' }); + // The stream reached its terminator, so the turn produced what it said it would. + expect(outcome.failed).toBe(false); + }); + + // A run that reached no upstream bills nothing and samples nothing: + // `recordPerformance` returns early without an attempt's telemetry, and there is no + // attempt. Settlement still runs — it is unconditional — and finds an + // empty billed set, which is how "we did not call an upstream" is said. + it('bills nothing and samples nothing when no upstream was reached', async () => { + vi.mocked(enumerateModelCandidates).mockResolvedValue({ candidates: [], sawModel: false, failedUpstreams: [] }); + await serve(entryFacts()); + expect(recorded.usage).toHaveLength(0); + expect(recorded.performance).toHaveLength(0); + }); + + it('fails a refusal over to the next candidate, and renders the last one it got', async () => { + const tried: string[] = []; + resolves([ + candidate('up_a', async () => { + tried.push('up_a'); + return { response: Response.json({ error: { message: 'slow down' } }, { status: 429 }), modelKey: 'text-model-key' }; + }), + candidate('up_b', async () => { + tried.push('up_b'); + return { response: Response.json({ error: { message: 'no' } }, { status: 400 }), modelKey: 'text-model-key' }; + }), + ]); + + const { facts, drain } = await serve(entryFacts({ + 'ingress.openaiCompletions.wantsStream': false, + 'request.openaiCompletions.payload': { model: 'text-model', prompt: 'hello' }, + })); + + expect(tried).toEqual(['up_a', 'up_b']); + // Every candidate failed, so the last failure is the base — the client sees the status + // an upstream actually returned, and the words that upstream used, rather than a + // synthesized envelope quoting its serialized body back as a message. + expect(facts['response.http.status']).toBe(400); + expect(facts['response.openaiCompletions.rendered']).toEqual({ error: { message: 'no' } }); + await drain(); + }); + + // Nothing in this family's own declarations says it must hand the upstream's body up, and + // nothing in assembly checks it: `failover` declares `provides: ['response.http.body']` on + // the way up, and that declaration is checked by the runner, at runtime, against whatever + // the stage below it handed on. A family whose ending stage parses the body and keeps it — + // which is what "no body is forwarded verbatim" invites — composes cleanly and then throws + // on its first request. The cast is the other half of the same story: the key rides in the + // record without being in the pipeline's exit type, because the path that refuses before + // any upstream is dialed has no body to hand up. + it('hands the upstream body up, because failover declares it provides one', async () => { + resolves([candidate('up_a', async () => ({ response: sse(chunk('hi'), '[DONE]'), modelKey: 'text-model-key' }))]); + + const { facts, drain } = await serve(entryFacts()); + + expect((facts as Record)['response.http.body']).toBeInstanceOf(ReadableStream); + await collect(facts['response.openaiCompletions.rendered']); + await drain(); + }); +}); diff --git a/packages/gateway/__tests__/data-plane/openai-completions/http_test.ts b/packages/gateway/__tests__/data-plane/openai-completions/http_test.ts index 4a3138d66d..738be26a3f 100644 --- a/packages/gateway/__tests__/data-plane/openai-completions/http_test.ts +++ b/packages/gateway/__tests__/data-plane/openai-completions/http_test.ts @@ -1,10 +1,12 @@ -import { test } from 'vitest'; +import { test, vi } from 'vitest'; import { initDumpBroker, initDumpStore } from '../../../src/dump/registry.ts'; import { tokenCountsFromUsage } from '../../../src/repo/usage-metrics.ts'; -import { installDumpStubs } from '../../dump/test-fixtures.ts'; +import { eventsOf, installDumpStubs, runRecordOf } from '../../dump/test-fixtures.ts'; import { saveUpstreamForTest } from '../../repo/upstreams.ts'; import { buildCustomUpstreamRecord, flushAsyncWork, requestApp as requestAppCold, requestAppWithWarmModels, setupAppTest, warmModelsForTest } from '../../test-utils/app.ts'; +import { createRunReader, type DumpEvent } from '@floway-dev/pipeline'; +import type { ProtocolFrame } from '@floway-dev/protocols/common'; import { clearInProcessCopilotTokenCache } from '@floway-dev/provider-copilot'; import { assertEquals, assertExists, jsonResponse, withMockedFetch } from '@floway-dev/test-utils'; @@ -130,7 +132,7 @@ test('/v1/completions non-streaming forwards body to upstream /v1/completions an await flushAsyncWork(); const usageRows = await repo.usage.listAll(); assertEquals(usageRows.length, 1); - assertEquals(tokenCountsFromUsage(usageRows[0]!), { input: 5, output: 1 }); + assertEquals(tokenCountsFromUsage(usageRows[0]!), { input: 5, input_cache_read: 0, input_cache_write: 0, output: 1 }); }); test('/v1/completions streaming forces stream_options.include_usage upstream', async () => { @@ -195,7 +197,7 @@ test('/v1/completions streaming strips usage chunk when client did not request i await flushAsyncWork(); const usageRows = await repo.usage.listAll(); assertEquals(usageRows.length, 1); - assertEquals(tokenCountsFromUsage(usageRows[0]!), { input: 4, output: 2 }); + assertEquals(tokenCountsFromUsage(usageRows[0]!), { input: 4, input_cache_read: 0, input_cache_write: 0, output: 2 }); }); test('/v1/completions streaming forwards usage chunk when the client opted in', async () => { @@ -343,7 +345,7 @@ test('/v1/completions non-streaming records usage row, performance neutral row ( const usage = await repo.usage.listAll(); assertEquals(usage.length, 1); assertEquals(usage[0]?.model, 'davinci-002'); - assertEquals(tokenCountsFromUsage(usage[0]!), { input: 7, output: 2 }); + assertEquals(tokenCountsFromUsage(usage[0]!), { input: 7, input_cache_read: 0, input_cache_write: 0, output: 2 }); const performance = await repo.performance.listAll(); assertEquals(performance.length, 1); @@ -352,15 +354,45 @@ test('/v1/completions non-streaming records usage row, performance neutral row ( assertEquals(performance[0]?.errorsNoOutput, 0); assertEquals(dumpStubs.stored.length, 1); - const dump = dumpStubs.stored[0]!.record; + const dump = runRecordOf(dumpStubs.stored[0]?.record); assertEquals(dump.meta.path, '/v1/completions'); assertEquals(dump.meta.status, 200); assertEquals(dump.meta.model, 'davinci-002'); assertEquals(dump.meta.inputTokens, 7); assertEquals(dump.meta.outputTokens, 2); - // Non-streaming: the upstream sent a one-shot JSON, so the dump - // captures the bytes (not a frame log). - assertEquals(dump.response.body.type, 'bytes'); + // The shape follows the endpoint: a pipelined turn is recorded as its whole run, so what + // is stored is the event stream rather than the two edges. The metadata is common to both, + // which is what lets the dashboard list them together. + assertEquals(runRecordOf(dumpStubs.stored[0]!.record).shape, 'run'); +}); + +// A stream that stops before its terminator did not produce what it said it would, and the +// performance row has to say so — a neutral row would report a turn that never finished as +// one that did. +test('/v1/completions a stream that never terminated is recorded as a failed request', async () => { + const { apiKey, repo } = await setupAppTest(); + await registerOpenAICompletionsUpstream(repo); + + await withMockedFetch( + () => Promise.resolve(new Response('data: {"id":"c","object":"text_completion","created":1,"model":"davinci-002","choices":[{"index":0,"text":"hi"}]}\n\n', { + status: 200, headers: { 'content-type': 'text/event-stream' }, + })), + async () => { + const response = await requestAppWithWarmModels('/v1/completions', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': apiKey.key }, + body: JSON.stringify({ model: 'davinci-002', prompt: 'hello', stream: true }), + }); + assertEquals(response.status, 200); + await response.text(); + }, + ); + + await flushAsyncWork(); + + const performance = await repo.performance.listAll(); + assertEquals(performance.length, 1); + assertEquals(performance[0]?.errorsNoOutput, 1); }); test('/v1/completions streaming records usage row, performance neutral row (text_completion operation, no TTFT/TPOT), and a frame-log dump record', async () => { @@ -369,6 +401,7 @@ test('/v1/completions streaming records usage row, performance neutral row (text await registerOpenAICompletionsUpstream(repo); const dumpStubs = installDumpStubs(initDumpStore, initDumpBroker); + let delivered = ''; await withMockedFetch( () => Promise.resolve(completionStream()), async () => { @@ -378,7 +411,7 @@ test('/v1/completions streaming records usage row, performance neutral row (text body: JSON.stringify({ model: 'davinci-002', prompt: 'hello', stream: true }), }); assertEquals(response.status, 200); - await response.text(); + delivered = await response.text(); }, ); @@ -386,7 +419,7 @@ test('/v1/completions streaming records usage row, performance neutral row (text const usage = await repo.usage.listAll(); assertEquals(usage.length, 1); - assertEquals(tokenCountsFromUsage(usage[0]!), { input: 4, output: 2 }); + assertEquals(tokenCountsFromUsage(usage[0]!), { input: 4, input_cache_read: 0, input_cache_write: 0, output: 2 }); const performance = await repo.performance.listAll(); assertEquals(performance.length, 1); @@ -394,32 +427,76 @@ test('/v1/completions streaming records usage row, performance neutral row (text assertEquals(performance[0]?.errorsNoOutput, 0); assertEquals(dumpStubs.stored.length, 1); - const dump = dumpStubs.stored[0]!.record; + const dump = runRecordOf(dumpStubs.stored[0]?.record); assertEquals(dump.meta.path, '/v1/completions'); assertEquals(dump.meta.status, 200); assertEquals(dump.meta.model, 'davinci-002'); assertEquals(dump.meta.inputTokens, 4); assertEquals(dump.meta.outputTokens, 2); - // Streaming: dump stores the protocol frames the gateway saw from - // upstream BEFORE transformFrame ran. The fixture stream emits two - // content events, one usage-only event (which the client did not opt - // into and so it was stripped from the forwarded stream), and a done - // terminator. - assertEquals(dump.response.body.type, 'stream'); - if (dump.response.body.type === 'stream') { - const frames = dump.response.body.events.map(e => e.frame); - assertEquals(frames.length, 4); - assertEquals(frames[0]?.type, 'event'); - assertEquals(frames[1]?.type, 'event'); - assertEquals(frames[2]?.type, 'event'); - // Upstream's usage chunk is preserved in the dump even though it was - // stripped from the client-facing stream. - const usageFrame = frames[2]; - if (usageFrame?.type === 'event') { - const event = usageFrame.event as { choices: unknown[]; usage: { prompt_tokens: number } }; - assertEquals(event.choices.length, 0); - assertEquals(event.usage.prompt_tokens, 4); + const events = eventsOf(runRecordOf(dumpStubs.stored[0]!.record)) as unknown as DumpEvent[]; + const read = createRunReader(); + const streams = new Map[]>(); + let client: number | undefined; + for (const event of events) { + const decoded = read(event); + if (decoded?.facts && 'response.openaiCompletions.rendered' in decoded.facts) { + client = (decoded.facts['response.openaiCompletions.rendered'] as { stream: number }).stream; } - assertEquals(frames[3]?.type, 'done'); + if (event.type === 'stream.frame') { + const frames = streams.get(event.streamId) ?? []; + frames.push(...decoded!.frames as ProtocolFrame[]); + streams.set(event.streamId, frames); + } + } + assertExists(client); + const clientFrames = streams.get(client)!; + assertEquals(clientFrames.length, 3); + assertEquals(clientFrames.map(frame => `data: ${frame.type === 'done' ? '[DONE]' : JSON.stringify(frame.event)}\n\n`).join(''), delivered); + assertEquals([...streams.values()].some(frames => frames.some(frame => frame.type === 'event' && 'usage' in (frame.event as object))), true); + assertEquals(events.filter(event => event.type === 'stream.end').map(event => event.streamId).sort(), [...streams.keys()].sort()); +}); + +// A run that threw is the turn that most needs explaining and used to be the one that left +// nothing behind: the record is closed at the seam, so an exception escaping past it lost the +// whole thing — no row, no events, no reason. It is answered there now, with the same envelope +// the app's own handler writes. +test('/v1/completions a run that threw is recorded, with the reason and a debuggable body', async () => { + const { apiKey, repo } = await setupAppTest(); + await repo.apiKeys.save({ ...apiKey, dumpRetentionSeconds: 3600 }); + await registerOpenAICompletionsUpstream(repo); + const dumpStubs = installDumpStubs(initDumpStore, initDumpBroker); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // Read inside `resolveCandidates`, which is a stage — so the throw happens with the run + // open and a record already accumulating. + repo.modelAliases.getByName = () => Promise.reject(new Error('alias lookup exploded')); + + try { + const response = await requestAppWithWarmModels('/v1/completions', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': apiKey.key }, + body: JSON.stringify({ model: 'davinci-002', prompt: 'hello' }), + }); + + assertEquals(response.status, 500); + const body = await response.json() as { error: { type: string; message: string; stack?: string } }; + assertEquals(body.error.type, 'internal_error'); + assertEquals(body.error.message, 'alias lookup exploded'); + assertExists(body.error.stack); + } finally { + errorSpy.mockRestore(); } + + await flushAsyncWork(); + + assertEquals(dumpStubs.stored.length, 1); + const dump = runRecordOf(dumpStubs.stored[0]?.record); + assertEquals(dump.meta.status, 500); + // The model the client asked for survives an outright failure, and the reason is on the row + // rather than only in the answer the client happened to receive. + assertEquals(dump.meta.model, 'davinci-002'); + assertEquals(dump.meta.error, { kind: 'failed', reason: 'alias lookup exploded' }); + // And the stages the run did get through are in it, which is what makes the record worth + // keeping: it says how far the turn got before it threw. + assertEquals(eventsOf(dump).some(event => event.type === 'stage.entered'), true); }); diff --git a/packages/gateway/__tests__/data-plane/openai-completions/usage_test.ts b/packages/gateway/__tests__/data-plane/openai-completions/usage_test.ts index daf3ef7075..5d2eac8481 100644 --- a/packages/gateway/__tests__/data-plane/openai-completions/usage_test.ts +++ b/packages/gateway/__tests__/data-plane/openai-completions/usage_test.ts @@ -6,7 +6,7 @@ import { assertEquals } from '@floway-dev/test-utils'; test('tokenUsageFromOpenAICompletionsUsage maps the OpenAI bare shape to bare input + output', () => { assertEquals( tokenUsageFromOpenAICompletionsUsage({ prompt_tokens: 12, completion_tokens: 3, total_tokens: 15 }, undefined, false, 'up/model'), - { input: 12, output: 3 }, + { input: 12, input_cache_read: 0, input_cache_write: 0, output: 3, tier: null }, ); }); @@ -19,7 +19,7 @@ test('tokenUsageFromOpenAICompletionsUsage splits prompt_tokens into cache_read { prompt_tokens: 100, completion_tokens: 7, total_tokens: 107, prompt_tokens_details: { cached_tokens: 80 } }, undefined, false, 'up/model', ), - { input: 20, input_cache_read: 80, output: 7 }, + { input: 20, input_cache_read: 80, input_cache_write: 0, output: 7, tier: null }, ); }); @@ -32,7 +32,7 @@ test('tokenUsageFromOpenAICompletionsUsage reads DeepSeek prompt_cache_hit_token { prompt_tokens: 200, completion_tokens: 5, total_tokens: 205, prompt_cache_hit_tokens: 128, prompt_cache_miss_tokens: 72 }, undefined, false, 'up/model', ), - { input: 72, input_cache_read: 128, output: 5 }, + { input: 72, input_cache_read: 128, input_cache_write: 0, output: 5, tier: null }, ); }); @@ -42,7 +42,7 @@ test('tokenUsageFromOpenAICompletionsUsage reads the flat top-level cached_token { prompt_tokens: 50, completion_tokens: 3, total_tokens: 53, cached_tokens: 32 }, undefined, false, 'up/model', ), - { input: 18, input_cache_read: 32, output: 3 }, + { input: 18, input_cache_read: 32, input_cache_write: 0, output: 3, tier: null }, ); }); @@ -73,15 +73,15 @@ test('tokenUsageFromOpenAICompletionsUsage runs serviceTier through billableServ // aggregate with rows that have no tier; null/undefined stays null. assertEquals( tokenUsageFromOpenAICompletionsUsage({ prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }, 'priority', false, 'up/model'), - { input: 5, output: 2, tier: 'priority' }, + { input: 5, input_cache_read: 0, input_cache_write: 0, output: 2, tier: 'priority' }, ); assertEquals( tokenUsageFromOpenAICompletionsUsage({ prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }, 'default', false, 'up/model'), - { input: 5, output: 2 }, + { input: 5, input_cache_read: 0, input_cache_write: 0, output: 2, tier: null }, ); assertEquals( tokenUsageFromOpenAICompletionsUsage({ prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }, null, false, 'up/model'), - { input: 5, output: 2 }, + { input: 5, input_cache_read: 0, input_cache_write: 0, output: 2, tier: null }, ); }); @@ -93,7 +93,7 @@ test('tokenUsageFromOpenAICompletionsUsage folds the cache counts back in on the { prompt_tokens: 479, completion_tokens: 373, total_tokens: 14164, prompt_tokens_details: { cached_tokens: 13312 } }, undefined, false, 'up/model', ), - { input: 479, input_cache_read: 13312, output: 373 }, + { input: 479, input_cache_read: 13312, input_cache_write: 0, output: 373, tier: null }, ); }); @@ -103,7 +103,7 @@ test('tokenUsageFromOpenAICompletionsUsage folds on the flag when the totals wit { prompt_tokens: 479, completion_tokens: 373, prompt_tokens_details: { cached_tokens: 13312 } }, undefined, true, 'up/model', ), - { input: 479, input_cache_read: 13312, output: 373 }, + { input: 479, input_cache_read: 13312, input_cache_write: 0, output: 373, tier: null }, ); }); @@ -112,3 +112,9 @@ test('tokenUsageFromOpenAICompletionsUsage returns null on malformed input', () assertEquals(tokenUsageFromOpenAICompletionsUsage({}, undefined, false, 'up/model'), null); assertEquals(tokenUsageFromOpenAICompletionsUsage({ prompt_tokens: 'no' }, undefined, false, 'up/model'), null); }); + +test('tokenUsageFromOpenAICompletionsUsage retains a measured zero', () => { + expect(tokenUsageFromOpenAICompletionsUsage({ prompt_tokens: 0, completion_tokens: 0 }, null, false, 'up/model')).toEqual({ + input: 0, input_cache_read: 0, input_cache_write: 0, output: 0, tier: null, + }); +}); diff --git a/packages/gateway/__tests__/data-plane/openai-embeddings-pipeline_test.ts b/packages/gateway/__tests__/data-plane/openai-embeddings-pipeline_test.ts new file mode 100644 index 0000000000..b29a7ac194 --- /dev/null +++ b/packages/gateway/__tests__/data-plane/openai-embeddings-pipeline_test.ts @@ -0,0 +1,25 @@ +// OpenAI Embeddings' pipeline, assembled. `compose` derives the entry contract and rejects an +// array that cannot work, so most of what this file establishes is established by the +// assembly succeeding at all — and what is worth writing down is the entry contract it +// derives, including the two keys it cannot see. + +import { describe, expect, it } from 'vitest'; + +import { openaiEmbeddingsServePipeline } from '../../src/data-plane/openai-embeddings/pipeline.ts'; +import { move, run } from '@floway-dev/pipeline'; + +describe('the OpenAI Embeddings pipeline', () => { + it('assembles, and asks its caller for what the descending stages need', () => { + expect([...openaiEmbeddingsServePipeline.entryNeeds].sort()).toEqual([ + 'ingress.http.headers', + 'ingress.openaiEmbeddings.encodingFormat', + 'request.openaiEmbeddings.canonical', + 'serve.model', + ]); + }); + + it('names the entry key a caller did not bring, before any stage runs', async () => { + await expect(run(openaiEmbeddingsServePipeline, move({ 'serve.model': 'text-embedding-3-small' }) as never, {})) + .rejects.toThrow('run(openaiEmbeddingsServe): openaiEmbeddingsServe needs'); + }); +}); diff --git a/packages/gateway/__tests__/data-plane/openai-embeddings/failures_test.ts b/packages/gateway/__tests__/data-plane/openai-embeddings/failures_test.ts new file mode 100644 index 0000000000..405cec3acb --- /dev/null +++ b/packages/gateway/__tests__/data-plane/openai-embeddings/failures_test.ts @@ -0,0 +1,155 @@ +// What a run does when something other than the answer goes wrong. Each of these was a +// behaviour of the passthrough serve that /v1/embeddings used to run through, and each is +// kept here against the pipeline that replaced it — two unchanged, one deliberately not. + +import { test, vi } from 'vitest'; + +import { saveUpstreamForTest } from '../../repo/upstreams.ts'; +import { buildCustomUpstreamRecord, flushAsyncWork, requestAppWithWarmModels as requestApp, setupAppTest } from '../../test-utils/app.ts'; +import { clearInProcessCopilotTokenCache } from '@floway-dev/provider-copilot'; +import { jsonResponse, withMockedFetch, assertEquals } from '@floway-dev/test-utils'; + +const registerOpenAIEmbeddingsUpstream = async ( + repo: Awaited>['repo'], +): Promise => { + await repo.upstreams.deleteAll(); + clearInProcessCopilotTokenCache(); + await saveUpstreamForTest(repo.upstreams, buildCustomUpstreamRecord({ + id: 'up_embeddings', + name: 'Embedding Provider', + sortOrder: 100, + config: { + baseUrl: 'https://embeddings.example.com', + authStyle: 'bearer', + ingressHeadersRules: [], + apiKey: 'sk-embeddings', + endpoints: {}, + }, + })); +}; + +const upstreamModels = () => jsonResponse({ object: 'list', data: [{ id: 'custom-embed-model' }] }); + +const askForOpenAIEmbeddings = async (key: string): Promise => await requestApp('/v1/embeddings', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': key }, + body: JSON.stringify({ model: 'custom-embed-model', input: 'hi' }), +}); + +// The answer does not depend on the row. Settlement hands the write to the background rather +// than awaiting it, so a repository that rejects cannot turn an upstream's 2xx into a 502 — +// and the failure is still reported, because a write nobody hears about is one nobody fixes. +test('a usage write that fails leaves the answer alone and still reports', async () => { + const { apiKey, repo } = await setupAppTest(); + await registerOpenAIEmbeddingsUpstream(repo); + + const failure = new Error('simulated SQL write failure'); + repo.usage.record = () => Promise.reject(failure); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + try { + await withMockedFetch( + request => { + const url = new URL(request.url); + if (url.pathname === '/v1/models') return upstreamModels(); + if (url.pathname === '/v1/embeddings') { + return jsonResponse({ + object: 'list', + model: 'custom-embed-model', + data: [{ object: 'embedding', index: 0, embedding: [0.5] }], + usage: { prompt_tokens: 3, total_tokens: 3 }, + }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const response = await askForOpenAIEmbeddings(apiKey.key); + + assertEquals(response.status, 200); + const body = await response.json() as { data: { embedding: number[] }[] }; + assertEquals(body.data[0].embedding, [0.5]); + await flushAsyncWork(); + }, + ); + + assertEquals(errorSpy.mock.calls.some(call => call[1] === failure), true); + } finally { + errorSpy.mockRestore(); + } +}); + +// A protocol that requires JSON and did not get JSON has no answer to serve, so the gateway +// says so itself — it cannot claim to have served a request whose answer it never read. +// Forwarding the body verbatim under the upstream's 200 would report success for a turn +// nothing in this gateway could read. +test('a 2xx body the OpenAI Embeddings protocol cannot read is refused rather than forwarded', async () => { + const { apiKey, repo } = await setupAppTest(); + await registerOpenAIEmbeddingsUpstream(repo); + + await withMockedFetch( + request => { + const url = new URL(request.url); + if (url.pathname === '/v1/models') return upstreamModels(); + if (url.pathname === '/v1/embeddings') { + return new Response(new Uint8Array([0xde, 0xad, 0xbe, 0xef]), { + status: 200, + headers: { 'content-type': 'application/octet-stream' }, + }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const response = await askForOpenAIEmbeddings(apiKey.key); + + assertEquals(response.status, 502); + assertEquals(response.headers.get('content-type'), 'application/json'); + const body = await response.json() as { error: { message: string } }; + assertEquals(body.error.message.includes('the OpenAI Embeddings protocol cannot read'), true); + await flushAsyncWork(); + }, + ); + + // An upstream that was called and reported nothing still counts as called: the row names + // the request, and a reading we could not parse is, from here, no reading rather than zero. + const usage = await repo.usage.listAll(); + assertEquals(usage.length, 1); + assertEquals(usage[0].requests, 1); + assertEquals(usage[0].metrics, []); +}); + +// The last failure is the one the client is answered with, and an upstream that refused in +// its own words is answered in them — with the status and the headers that carry what a +// client does next. +test('when every candidate refuses the client gets the last upstream-s own refusal', async () => { + const { apiKey, repo } = await setupAppTest(); + await repo.upstreams.deleteAll(); + clearInProcessCopilotTokenCache(); + for (const [id, host, order] of [['up_a', 'up-a.example.com', 100], ['up_b', 'up-b.example.com', 200]] as const) { + await saveUpstreamForTest(repo.upstreams, buildCustomUpstreamRecord({ + id, name: id, sortOrder: order, + config: { baseUrl: `https://${host}`, authStyle: 'bearer', ingressHeadersRules: [], apiKey: 'sk-x', endpoints: {} }, + })); + } + + await withMockedFetch( + request => { + const url = new URL(request.url); + if (url.pathname === '/v1/models') return upstreamModels(); + if (url.hostname === 'up-a.example.com') return new Response('first upstream unavailable', { status: 503 }); + if (url.hostname === 'up-b.example.com') { + return new Response(JSON.stringify({ error: { message: 'rate limited' } }), { + status: 429, headers: { 'content-type': 'application/json', 'retry-after': '17' }, + }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const response = await askForOpenAIEmbeddings(apiKey.key); + + assertEquals(response.status, 429); + assertEquals(response.headers.get('retry-after'), '17'); + assertEquals(await response.json(), { error: { message: 'rate limited' } }); + await flushAsyncWork(); + }, + ); +}); diff --git a/packages/gateway/__tests__/data-plane/openai-embeddings/http_test.ts b/packages/gateway/__tests__/data-plane/openai-embeddings/http_test.ts index cbcae03cc6..0d28711d2f 100644 --- a/packages/gateway/__tests__/data-plane/openai-embeddings/http_test.ts +++ b/packages/gateway/__tests__/data-plane/openai-embeddings/http_test.ts @@ -6,6 +6,63 @@ import { buildCustomUpstreamRecord, copilotModels, flushAsyncWork, requestAppWit import { clearInProcessCopilotTokenCache } from '@floway-dev/provider-copilot'; import { jsonResponse, withMockedFetch, assertEquals, assertExists } from '@floway-dev/test-utils'; +test('/v1/embeddings preserves an upstream success status', async () => { + const { apiKey, repo } = await setupAppTest(); + await repo.upstreams.deleteAll(); + await saveUpstreamForTest(repo.upstreams, buildCustomUpstreamRecord({ + config: { + baseUrl: 'https://embeddings.test', authStyle: 'bearer', apiKey: 'sk-test', ingressHeadersRules: [], endpoints: {}, + modelsFetch: { enabled: false }, + models: [{ upstreamModelId: 'embedding', kind: 'embedding', endpoints: { openaiEmbeddings: {} } }], + }, + })); + await withMockedFetch(async () => jsonResponse({ + object: 'list', model: 'embedding', data: [{ object: 'embedding', index: 0, embedding: [0.5] }], + usage: { prompt_tokens: 1, total_tokens: 1 }, + }, 201), async () => { + const response = await requestAppWithWarmModels('/v1/embeddings', { + method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': apiKey.key }, + body: JSON.stringify({ model: 'embedding', input: 'hello', encoding_format: 'float' }), + }); + assertEquals(response.status, 201); + assertEquals((await response.json() as { data: unknown[] }).data.length, 1); + }); +}); + +test('/v1/embeddings keeps distinct alias candidates on the same upstream through failover', async () => { + const { apiKey, repo } = await setupAppTest(); + await repo.upstreams.deleteAll(); + await saveUpstreamForTest(repo.upstreams, buildCustomUpstreamRecord({ + config: { + baseUrl: 'https://same-upstream.test', authStyle: 'bearer', apiKey: 'sk-test', ingressHeadersRules: [], endpoints: {}, + modelsFetch: { enabled: false }, + models: ['first', 'second'].map(upstreamModelId => ({ upstreamModelId, kind: 'embedding', endpoints: { openaiEmbeddings: {} } })), + }, + })); + await repo.modelAliases.insert({ + id: 'alias_embedding', name: 'embeddings', kind: 'embedding', selection: 'first-available', + targets: [{ target_model_id: 'first', rules: {} }, { target_model_id: 'second', rules: {} }], + displayName: null, visibleInModelsList: true, announcedMetadata: null, sortOrder: 0, + createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', + }); + const dispatched: string[] = []; + await withMockedFetch(async request => { + const body = await request.json() as { model: string }; + dispatched.push(body.model); + return body.model === 'first' + ? jsonResponse({ error: { message: 'try next' } }, 429) + : jsonResponse({ object: 'list', model: 'second', data: [{ object: 'embedding', index: 0, embedding: [0.5] }], usage: { prompt_tokens: 1, total_tokens: 1 } }); + }, async () => { + const response = await requestAppWithWarmModels('/v1/embeddings', { + method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': apiKey.key }, + body: JSON.stringify({ model: 'embeddings', input: 'hello', encoding_format: 'float' }), + }); + assertEquals(response.status, 200); + await response.json(); + }); + assertEquals(dispatched, ['first', 'second']); +}); + test('/v1/embeddings wraps scalar string input for Copilot upstream', async () => { const { apiKey } = await setupAppTest(); let forwardedBody: diff --git a/packages/gateway/__tests__/data-plane/openai-images-pipeline_test.ts b/packages/gateway/__tests__/data-plane/openai-images-pipeline_test.ts new file mode 100644 index 0000000000..85998bcfcb --- /dev/null +++ b/packages/gateway/__tests__/data-plane/openai-images-pipeline_test.ts @@ -0,0 +1,48 @@ +// OpenAI Images' pipeline, assembled. `compose` derives the entry contract and rejects an array +// that cannot work, so most of what this file establishes is established by the assembly +// succeeding at all — and what is worth writing down is the contract it derives and the keys it +// cannot see. + +import { describe, expect, it } from 'vitest'; + +import { type OpenAIImagesServeEntry } from '../../src/data-plane/openai-images/facts.ts'; +import { openaiImagesServePipeline } from '../../src/data-plane/openai-images/pipeline.ts'; +import type { CanonicalOpenAIImagesRequest } from '@floway-dev/protocols/openai-images'; + +const generations: CanonicalOpenAIImagesRequest = { + operation: 'generations', + parameters: { prompt: 'a shiba in space' }, +}; + +const edits: CanonicalOpenAIImagesRequest = { + operation: 'edits', + images: [{ kind: 'reference', reference: { file_id: 'file-source' } }], + parameters: { prompt: 'replace the sky' }, +}; + +describe('the OpenAI Images pipeline', () => { + it('assembles both endpoints as one array, and asks its caller for what the descending stages need', () => { + expect([...openaiImagesServePipeline(generations).entryNeeds].sort()).toEqual(['ingress.http.headers', 'ingress.openaiImages.wantsStream', 'request.openaiImages.canonical', 'serve.model']); + expect([...openaiImagesServePipeline(edits).entryNeeds].sort()).toEqual(['ingress.http.headers', 'ingress.openaiImages.wantsStream', 'request.openaiImages.canonical', 'serve.model']); + }); + + it('names every key a caller must bring in its entry type', () => { + const entry: OpenAIImagesServeEntry = { + 'ingress.http.headers': [['content-type', 'application/json']], + 'ingress.openaiImages.wantsStream': false, + 'request.openaiImages.canonical': generations, + 'serve.model': 'gpt-image-1', + }; + expect(Object.keys(entry).sort()).toEqual([ + 'ingress.http.headers', + 'ingress.openaiImages.wantsStream', + 'request.openaiImages.canonical', + 'serve.model', + ]); + + // @ts-expect-error — dropping one of them is a compile error, which is the statement this + // makes; the assertion below only keeps the binding from being unused. + const incomplete: OpenAIImagesServeEntry = { 'serve.model': 'gpt-image-1' }; + expect(Object.keys(incomplete)).toEqual(['serve.model']); + }); +}); diff --git a/packages/gateway/__tests__/data-plane/openai-images/stream_test.ts b/packages/gateway/__tests__/data-plane/openai-images/stream_test.ts new file mode 100644 index 0000000000..6d959bf258 --- /dev/null +++ b/packages/gateway/__tests__/data-plane/openai-images/stream_test.ts @@ -0,0 +1,314 @@ +import { expect, test, vi } from 'vitest'; + +import { initDumpBroker, initDumpStore } from '../../../src/dump/registry.ts'; +import { installDumpStubs, runRecordOf } from '../../dump/test-fixtures.ts'; +import type { InMemoryRepo } from '../../repo/memory.ts'; +import { saveUpstreamForTest } from '../../repo/upstreams.ts'; +import { buildCustomUpstreamRecord, flushAsyncWork, requestAppWithWarmModels as requestApp, setupAppTest } from '../../test-utils/app.ts'; +import { clearInProcessCopilotTokenCache } from '@floway-dev/provider-copilot'; +import { withMockedFetch, assertEquals, assertExists } from '@floway-dev/test-utils'; + +// The upstream is declared rather than discovered: both OpenAI Images endpoints are named on +// one manual model row, so nothing in these cases depends on the id heuristics that infer a +// catalog's capabilities. +const registerOpenAIImagesModel = async (repo: InMemoryRepo): Promise => { + await repo.upstreams.deleteAll(); + clearInProcessCopilotTokenCache(); + await saveUpstreamForTest(repo.upstreams, buildCustomUpstreamRecord({ + id: 'up_images', + name: 'Image Provider', + sortOrder: 100, + config: { + baseUrl: 'https://images.example.com', + authStyle: 'bearer', + ingressHeadersRules: [], + apiKey: 'sk-images', + endpoints: {}, + modelsFetch: { enabled: false }, + models: [{ + upstreamModelId: 'gpt-image-2-upstream', + publicModelId: 'gpt-image-2', + kind: 'image', + endpoints: { openaiImagesGenerations: {}, openaiImagesEdits: {} }, + }], + }, + })); +}; + +const PARTIAL = 'event: image_generation.partial_image\ndata: {"type":"image_generation.partial_image","b64_json":"UDA=","partial_image_index":0}\n\n'; +const COMPLETED = 'event: image_generation.completed\ndata: {"type":"image_generation.completed","b64_json":"RklO","usage":{"total_tokens":100,"input_tokens":50,"output_tokens":50,"input_tokens_details":{"text_tokens":10,"image_tokens":40}}}\n\n'; + +const sseResponse = (body: string, headers: Record = {}): Response => + new Response(body, { status: 200, headers: { 'content-type': 'text/event-stream', ...headers } }); + +test('/v1/images/generations answers a streaming request with the upstream events as SSE', async () => { + const { apiKey, repo } = await setupAppTest(); + await registerOpenAIImagesModel(repo); + let forwarded: Record | undefined; + + await withMockedFetch( + async request => { + const url = new URL(request.url); + if (url.hostname === 'images.example.com' && url.pathname === '/v1/images/generations') { + forwarded = await request.json() as Record; + return sseResponse(PARTIAL + COMPLETED, { 'x-image-trace': 'trace-stream', 'set-cookie': 'upstream-session=secret' }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const response = await requestApp('/v1/images/generations', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': apiKey.key }, + body: JSON.stringify({ model: 'gpt-image-2', prompt: 'a shiba in space', stream: true, partial_images: 1 }), + }); + assertEquals(response.status, 200); + assertEquals(response.headers.get('content-type'), 'text/event-stream'); + // Vendor traces stay visible; an upstream session does not. + assertEquals(response.headers.get('x-image-trace'), 'trace-stream'); + assertEquals(response.headers.get('set-cookie'), null); + + const stream = await response.text(); + assertEquals(stream.includes('event: image_generation.partial_image'), true); + assertEquals(stream.includes('"partial_image_index":0'), true); + assertEquals(stream.includes('event: image_generation.completed'), true); + assertEquals(stream.includes('"b64_json":"RklO"'), true); + // This protocol ends at its completed event; the sentinel the chat dialects use is not + // part of it and is not invented here. + assertEquals(stream.includes('[DONE]'), false); + }, + ); + + // The flag rides to the upstream exactly as the client wrote it — the gateway asks for the + // stream the client asked for, having no usage chunk of its own to turn on. + assertExists(forwarded); + assertEquals(forwarded.stream, true); + assertEquals(forwarded.partial_images, 1); + assertEquals(forwarded.model, 'gpt-image-2-upstream'); +}); + +test('/v1/images/generations answers a non-streaming request with the JSON object', async () => { + const { apiKey, repo } = await setupAppTest(); + await registerOpenAIImagesModel(repo); + + await withMockedFetch( + request => { + const url = new URL(request.url); + if (url.hostname === 'images.example.com' && url.pathname === '/v1/images/generations') { + return Promise.resolve(Response.json({ data: [{ b64_json: 'aGVsbG8=' }], usage: { input_tokens: 10, output_tokens: 50 } })); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const response = await requestApp('/v1/images/generations', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': apiKey.key }, + body: JSON.stringify({ model: 'gpt-image-2', prompt: 'a shiba in space', stream: false }), + }); + assertEquals(response.status, 200); + assertEquals(response.headers.get('content-type'), 'application/json'); + assertEquals(await response.json(), { data: [{ b64_json: 'aGVsbG8=' }], usage: { input_tokens: 10, output_tokens: 50 } }); + }, + ); +}); + +test('/v1/images/generations bills a stream from the usage its completed event reported, once', async () => { + const { apiKey, repo } = await setupAppTest(); + await registerOpenAIImagesModel(repo); + + await withMockedFetch( + () => Promise.resolve(sseResponse(PARTIAL + COMPLETED)), + async () => { + const response = await requestApp('/v1/images/generations', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': apiKey.key }, + body: JSON.stringify({ model: 'gpt-image-2', prompt: 'a shiba in space', stream: true }), + }); + assertEquals(response.status, 200); + await response.text(); + }, + ); + + await flushAsyncWork(); + const rows = await repo.usage.listAll(); + assertEquals(rows.length, 1); + // The numbers arrive with the completed event, long after the run answered, so a row that + // carries them is a row settled from the promise the run handed up. Settling in the stage as + // well would have counted the request twice. + assertEquals(rows[0]?.requests, 1); + assertEquals(rows[0]?.metrics.map(row => ({ metric: row.metric, quantity: row.quantity })), [ + { metric: 'input_tokens', quantity: '10' }, + { metric: 'input_image_tokens', quantity: '40' }, + { metric: 'output_tokens', quantity: '50' }, + ]); + const [performance] = await repo.performance.listAll(); + assertEquals(performance?.requests, 1); +}); + +test('/v1/images/generations answers a refused stream in the upstream status and words', async () => { + const { apiKey, repo } = await setupAppTest(); + await registerOpenAIImagesModel(repo); + + await withMockedFetch( + () => Promise.resolve(new Response(JSON.stringify({ error: { message: 'Rate limit reached for images.', code: 'rate_limit_exceeded' } }), { + status: 429, + headers: { 'content-type': 'application/json', 'retry-after': '7', 'x-error-trace': 'trace-refused' }, + })), + async () => { + const response = await requestApp('/v1/images/generations', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': apiKey.key }, + body: JSON.stringify({ model: 'gpt-image-2', prompt: 'a shiba in space', stream: true }), + }); + // A request that asked to stream but was refused is answered as the refusal it is. + assertEquals(response.status, 429); + assertEquals(response.headers.get('content-type'), 'application/json'); + assertEquals(response.headers.get('retry-after'), '7'); + assertEquals(response.headers.get('x-error-trace'), 'trace-refused'); + assertEquals(await response.json(), { error: { message: 'Rate limit reached for images.', code: 'rate_limit_exceeded' } }); + }, + ); + + await flushAsyncWork(); + const [performance] = await repo.performance.listAll(); + assertEquals(performance?.errorsNoOutput, 1); +}); + +test('/v1/images/generations completes and cancels an upstream kept open after the completed event', async () => { + const { apiKey, repo } = await setupAppTest(); + await registerOpenAIImagesModel(repo); + let upstreamCancelled = false; + const encoder = new TextEncoder(); + + await withMockedFetch( + () => Promise.resolve(new Response(new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(PARTIAL)); + controller.enqueue(encoder.encode(COMPLETED)); + // Deliberately never closed: the image is done and the connection is not. + }, + cancel() { + upstreamCancelled = true; + }, + }), { headers: { 'content-type': 'text/event-stream' } })), + async () => { + const response = await requestApp('/v1/images/generations', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': apiKey.key }, + body: JSON.stringify({ model: 'gpt-image-2', prompt: 'a shiba in space', stream: true }), + }); + const stream = await response.text(); + assertEquals(stream.includes('event: image_generation.completed'), true); + }, + ); + + assertEquals(upstreamCancelled, true); +}); + +test('/v1/images/generations fails a stream that ended without a completed event', async () => { + const { apiKey, repo } = await setupAppTest(); + await registerOpenAIImagesModel(repo); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + try { + await withMockedFetch( + () => Promise.resolve(sseResponse(PARTIAL)), + async () => { + const response = await requestApp('/v1/images/generations', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': apiKey.key }, + body: JSON.stringify({ model: 'gpt-image-2', prompt: 'a shiba in space', stream: true }), + }); + // The answer had already begun, so what the client keeps is what it was already sent. + assertEquals(response.status, 200); + const stream = await response.text(); + assertEquals(stream.includes('event: image_generation.partial_image'), true); + assertEquals(stream.includes('image_generation.completed'), false); + }, + ); + // An upstream that sent partial images and no image never answered the request, and the + // run says so rather than letting a truncated stream read as a complete one. + assertEquals( + errorSpy.mock.calls.some(call => call.some(arg => arg instanceof Error && arg.message === 'OpenAI Images stream ended without a completed event.')), + true, + ); + } finally { + errorSpy.mockRestore(); + } +}); + +test('/v1/images/edits streams a multipart request, whose stream field arrives as text', async () => { + const { apiKey, repo } = await setupAppTest(); + await registerOpenAIImagesModel(repo); + let upstreamForm: FormData | undefined; + + await withMockedFetch( + async request => { + const url = new URL(request.url); + if (url.hostname === 'images.example.com' && url.pathname === '/v1/images/edits') { + upstreamForm = await request.formData(); + return sseResponse( + 'event: image_edit.partial_image\ndata: {"type":"image_edit.partial_image","b64_json":"UDA=","partial_image_index":0}\n\n' + + 'event: image_edit.completed\ndata: {"type":"image_edit.completed","b64_json":"RklO","usage":{"total_tokens":20,"input_tokens":8,"output_tokens":12,"input_tokens_details":{"text_tokens":3,"image_tokens":5}}}\n\n', + ); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const form = new FormData(); + form.append('model', 'gpt-image-2'); + form.append('prompt', 'replace the sky'); + form.append('stream', 'true'); + form.append('image', new Blob([new Uint8Array([1, 2, 3])], { type: 'image/png' }), 'photo.png'); + const response = await requestApp('/v1/images/edits', { + method: 'POST', headers: { 'x-api-key': apiKey.key }, body: form, + }); + assertEquals(response.status, 200); + assertEquals(response.headers.get('content-type'), 'text/event-stream'); + const stream = await response.text(); + assertEquals(stream.includes('event: image_edit.partial_image'), true); + assertEquals(stream.includes('event: image_edit.completed'), true); + }, + ); + + assertExists(upstreamForm); + assertEquals(upstreamForm.get('stream'), 'true'); + + await flushAsyncWork(); + const rows = await repo.usage.listAll(); + assertEquals(rows.length, 1); + assertEquals(rows[0]?.metrics.map(row => ({ metric: row.metric, quantity: row.quantity })), [ + { metric: 'input_tokens', quantity: '3' }, + { metric: 'input_image_tokens', quantity: '5' }, + { metric: 'output_tokens', quantity: '12' }, + ]); +}); + +test('rejects invalid streamed image quantities and completes owned background work', async () => { + const { apiKey, repo } = await setupAppTest(); + await repo.apiKeys.save({ ...apiKey, dumpRetentionSeconds: 3600 }); + await registerOpenAIImagesModel(repo); + const dumps = installDumpStubs(initDumpStore, initDumpBroker); + const reported = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + await withMockedFetch( + () => Promise.resolve(sseResponse('data: {"type":"image_generation.completed","b64_json":"RklO","usage":{"input_tokens":1e999,"output_tokens":3}}\n\n')), + async () => { + const response = await requestApp('/v1/images/generations', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': apiKey.key }, + body: JSON.stringify({ model: 'gpt-image-2', prompt: 'image', stream: true }), + }); + expect(await response.text()).toContain('image_generation.completed'); + await flushAsyncWork(); + const errors = reported.mock.calls.flat().filter(value => value instanceof TypeError && value.message.includes('Infinity')); + expect(errors.length).toBeGreaterThan(0); + expect(new Set(errors).size).toBe(1); + }, + ); + const [usage] = await repo.usage.listAll(); + expect(usage).toMatchObject({ requests: 1, metrics: [] }); + const [performance] = await repo.performance.listAll(); + expect(performance).toMatchObject({ requests: 1, errorsNoOutput: 1 }); + expect(runRecordOf(dumps.stored[0]?.record).meta).toMatchObject({ status: 200, upstream: { id: 'up_images' }, error: { kind: 'failed', reason: expect.stringContaining('Infinity') } }); + } finally { reported.mockRestore(); } +}); diff --git a/packages/gateway/__tests__/data-plane/pipeline/assembly_test.ts b/packages/gateway/__tests__/data-plane/pipeline/assembly_test.ts new file mode 100644 index 0000000000..7d1fce498b --- /dev/null +++ b/packages/gateway/__tests__/data-plane/pipeline/assembly_test.ts @@ -0,0 +1,32 @@ +// Every family assembles. Assembly is a check over declarations, and a family whose +// pipeline is built from the request assembles only when something builds one — so a +// pipeline that could never run can sit in the tree looking fine. This test builds all of +// them, which is what turns "the declarations disagree" into a failure at `pnpm test` +// rather than on the first request that reaches the route. + +import { describe, expect, it } from 'vitest'; + +import { searchServePipeline } from '../../../src/data-plane/alpha-search/pipeline.ts'; +import { openaiAudioTranscriptionServePipeline } from '../../../src/data-plane/openai-audio/pipeline.ts'; +import { openaiCompletionsServePipeline } from '../../../src/data-plane/openai-completions/pipeline.ts'; +import { openaiEmbeddingsServePipeline } from '../../../src/data-plane/openai-embeddings/pipeline.ts'; +import { openaiImagesServePipeline } from '../../../src/data-plane/openai-images/pipeline.ts'; +import { rerankServePipeline } from '../../../src/data-plane/rerank/pipeline.ts'; + +const FAMILIES: readonly (readonly [string, () => { readonly name: string }])[] = [ + ['OpenAI Embeddings', () => openaiEmbeddingsServePipeline], + ['rerank', () => rerankServePipeline({ sourceProtocol: 'cohere-v2', raw: {}, query: 'q', documents: ['a'] } as never)], + ['OpenAI Images Generations', () => openaiImagesServePipeline({ operation: 'generations', parameters: {} } as never)], + ['OpenAI Images Edits', () => openaiImagesServePipeline({ operation: 'edits', images: [], parameters: {} } as never)], + ['OpenAI Completions', () => openaiCompletionsServePipeline], + ['OpenAI Audio Transcriptions', () => openaiAudioTranscriptionServePipeline], + ['alpha search', () => searchServePipeline({ kind: 'search' } as never)], +]; + +describe('every family assembles', () => { + for (const [name, build] of FAMILIES) { + it(`${name} composes into a pipeline`, () => { + expect(build().name).toBeTypeOf('string'); + }); + } +}); diff --git a/packages/gateway/__tests__/data-plane/pipeline/candidates_test.ts b/packages/gateway/__tests__/data-plane/pipeline/candidates_test.ts new file mode 100644 index 0000000000..9416a94d15 --- /dev/null +++ b/packages/gateway/__tests__/data-plane/pipeline/candidates_test.ts @@ -0,0 +1,18 @@ +import { expect, it } from 'vitest'; + +import { createCandidateRegistry } from '../../../src/data-plane/pipeline/candidates.ts'; +import { stubModelCandidate } from '@floway-dev/test-utils'; + +it('preserves exact candidate identity across same-upstream variants and later registrations', () => { + const registry = createCandidateRegistry(); + const first = stubModelCandidate({ model: { id: 'first' } }); + const second = { ...first, rules: { verbosity: 'high' } }; + const later = stubModelCandidate({ model: { id: 'later' } }); + const [firstSelector, secondSelector] = registry.rememberCandidates([first, second]); + const [laterSelector] = registry.rememberCandidates([later]); + expect([firstSelector!.candidateId, secondSelector!.candidateId, laterSelector!.candidateId]).toEqual([0, 1, 2]); + expect(registry.resolveAttempt(firstSelector!)).toBe(first); + expect(registry.resolveAttempt(secondSelector!)).toBe(second); + expect(registry.resolveAttempt(laterSelector!)).toBe(later); + expect(() => registry.resolveAttempt({ ...firstSelector!, candidateId: 99 })).toThrow('nothing live for candidate 99'); +}); diff --git a/packages/gateway/__tests__/data-plane/pipeline/serialize-client-json_test.ts b/packages/gateway/__tests__/data-plane/pipeline/serialize-client-json_test.ts new file mode 100644 index 0000000000..f962d22580 --- /dev/null +++ b/packages/gateway/__tests__/data-plane/pipeline/serialize-client-json_test.ts @@ -0,0 +1,73 @@ +import { expect, test } from 'vitest'; + +import type { GatewayFacts } from '../../../src/data-plane/pipeline/facts.ts'; +import { serializeClientJson } from '../../../src/data-plane/pipeline/serialize-client-json.ts'; +import { writeSettlement } from '../../../src/data-plane/pipeline/settlement.ts'; +import { initRepo } from '../../../src/repo/index.ts'; +import { InMemoryRepo } from '../../repo/memory.ts'; +import { mockGatewayCtx } from '../../test-utils/gateway-ctx.ts'; +import { compose, defineStage, getFailureFacts, move, run, type Event } from '@floway-dev/pipeline'; +import { mockPerfTelemetryContext } from '@floway-dev/test-utils'; + +const renderedKey = 'response.test.rendered'; +const answer = (value: unknown) => defineStage, Record & Pick>({ + name: 'answer', + return: { provides: [renderedKey, 'response.usage.billable', 'response.http.status'] }, + execute: async facts => move({ + ...facts, [renderedKey]: value, 'response.http.status': 200, + 'response.usage.billable': [{ + identity: { model: 'm', upstream: 'u', modelKey: 'm', pricing: null }, + quantities: { input_tokens: '3', output_tokens: '2' }, + }], + }), +}); + +test('serializes client JSON within the run while preserving the canonical object', async () => { + const value = move({ text: '中' }); + const pipeline = compose, Record>('json', [serializeClientJson(renderedKey), answer(value)]); + const executed = await run(pipeline, move({}), {}); + expect(executed.facts[renderedKey]).toBe(value); + expect(new TextDecoder().decode(executed.facts['response.http.jsonBody'] as Uint8Array)).toBe('{"text":"中"}'); + await executed.drain(); +}); + +test('rejects a client value without a JSON representation', async () => { + await expect(run(compose('invalidJson', [serializeClientJson(renderedKey), answer(undefined)]), move({}), {})) + .rejects.toThrow('Client JSON body has no JSON representation'); +}); + +test('leaves streams and upstream documents to their owning transport', async () => { + for (const value of [new Uint8Array([0, 255, 1]), { async *[Symbol.asyncIterator]() { yield 'frame'; } }]) { + const pipeline = compose, Record>('nonJson', [serializeClientJson(renderedKey), answer(value)]); + const executed = await run(pipeline, move({}), {}); + expect(executed.facts[renderedKey]).toBe(value); + expect(executed.facts['response.http.jsonBody']).toBeNull(); + await executed.drain(); + } +}); + +test('a JSON encoding fault settles measured usage as a failure and retains the failure facts', async () => { + const repo = new InMemoryRepo(); initRepo(repo); + const value = move({ extension: 1n }); + const work: Promise[] = []; + const events: Event[] = []; + const gateway = mockGatewayCtx({ + attempt: { + timing: { upstreamCallStartedAt: 50, firstOutputTokenAt: 100 }, + telemetry: mockPerfTelemetryContext(), + }, + }); + const pipeline = compose('encodingFailure', [ + writeSettlement(() => false), serializeClientJson(renderedKey), answer(value), + ]); + const error = await run(pipeline, move({}), { + gateway, background: (promise: Promise) => { work.push(promise); }, + dump: (event: Event) => { events.push(event); }, + }).then(() => { throw new Error('Expected JSON serialization to reject'); }, error => error as unknown); + expect(error).toBeInstanceOf(TypeError); + expect(getFailureFacts(error)?.[renderedKey]).toBe(value); + expect(events.find((event): event is Extract => event.type === 'stage.failed' && event.stageId === 2)?.error).toBe(error); + await Promise.all(work); + expect(await repo.usage.listAll()).toMatchObject([{ requests: 1, metrics: expect.arrayContaining([{ metric: 'input_tokens', quantity: '3', unitPrice: null }, { metric: 'output_tokens', quantity: '2', unitPrice: null }]) }]); + expect(await repo.performance.listAll()).toMatchObject([{ errorsWithOutput: 1, errorsNoOutput: 0 }]); +}); diff --git a/packages/gateway/__tests__/data-plane/pipeline/settlement_test.ts b/packages/gateway/__tests__/data-plane/pipeline/settlement_test.ts new file mode 100644 index 0000000000..bcf81f87ec --- /dev/null +++ b/packages/gateway/__tests__/data-plane/pipeline/settlement_test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GatewayFacts } from '../../../src/data-plane/pipeline/facts.ts'; +import { failover } from '../../../src/data-plane/pipeline/failover.ts'; +import { settleBillable, writeSettlement } from '../../../src/data-plane/pipeline/settlement.ts'; +import { initDumpBroker, initDumpStore } from '../../../src/dump/registry.ts'; +import { openRunDump } from '../../../src/dump/run-sink.ts'; +import { initRepo } from '../../../src/repo/index.ts'; +import { installDumpStubs, runRecordOf } from '../../dump/test-fixtures.ts'; +import { InMemoryRepo } from '../../repo/memory.ts'; +import { mockGatewayCtx } from '../../test-utils/gateway-ctx.ts'; +import { compose, defineStage, move, run } from '@floway-dev/pipeline'; +import { mockPerfTelemetryContext } from '@floway-dev/test-utils'; + +describe('pipeline settlement', () => { + it.each([false, true])('keeps call rows, totals and output performance on failed=%s', async failed => { + const repo = new InMemoryRepo(); + initRepo(repo); + const stubs = installDumpStubs(initDumpStore, initDumpBroker); + const pending: Promise[] = []; + const background = (work: Promise) => { pending.push(work); }; + const key = { + id: 'key_test', userId: 1, name: 'Test key', key: 'raw-key', serverSecret: '11'.repeat(32), + createdAt: '2026-01-01T00:00:00.000Z', upstreamIds: null, randomizeUpstreams: false, deletedAt: null, + dumpRetentionSeconds: 3600, openaiResponsesRetentionSeconds: 0, + }; + const recordUsage = vi.spyOn(repo.usage, 'record'); + const timing = { upstreamCallStartedAt: 50, firstOutputTokenAt: 100 }; + const dump = openRunDump(key, { + method: 'POST', path: '/v1/responses', body: { bytes: new Uint8Array(), streamError: null }, + }, background, true, timing); + const gateway = { + ...mockGatewayCtx({ + backgroundScheduler: background, + attempt: { timing, telemetry: mockPerfTelemetryContext({ keyId: key.id }) }, + }), + dump, + }; + const identity = { model: 'm', upstream: 'u', modelKey: 'm', pricing: null }; + if (failed) dump!.failed(new Error('Original source failure', { cause: new Error('socket reset') })); + const active = pending.length; + settleBillable({ gateway, background, log: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} } }, [ + { identity, quantities: { input_tokens: '10', output_tokens: '2' } }, + { identity, quantities: { input_tokens: '20', output_tokens: '3' } }, + ], failed, 500); + await Promise.all(pending.slice(active)); + const usage = await repo.usage.listAll(); + expect(recordUsage.mock.calls.map(([row]) => row.metrics.map(metric => metric.quantity))).toEqual([['10', '2'], ['20', '3']]); + expect(usage).toMatchObject([{ requests: 2 }]); + expect(await repo.performance.listAll()).toMatchObject([{ + requests: 1, tpotSamples: 1, tpotUsSum: 100_000, + errorsWithOutput: failed ? 1 : 0, errorsNoOutput: 0, + }]); + dump!.finalize(200, 0); + await Promise.all(pending); + expect(runRecordOf(stubs.stored[0]?.record).meta).toMatchObject({ + inputTokens: 30, outputTokens: 5, model: 'm', + error: failed ? { kind: 'failed', reason: 'Original source failure' } : null, + }); + }); + it('retains earlier candidate calls when a later stage throws after reading current usage', async () => { + const repo = new InMemoryRepo(); initRepo(repo); + const work: Promise[] = []; + const original = new Error('post-reading stage failure'); + type Entry = Pick; + type Reply = Entry & Pick; + const afterReading = defineStage({ + name: 'afterReading', + through: { request: { needs: ['route.attempt'], consumes: [], provides: [] }, response: { needs: ['response.usage.billable'], consumes: [], provides: [] } }, + execute: async (facts, next) => { + const back = await next({ ...facts }); + if (facts['route.attempt'].upstreamId === 'second') throw original; + return { ...back }; + }, + }); + const source = defineStage({ + name: 'observe', return: { provides: ['response.usage.billable', 'response.http.status'] }, + execute: async facts => move({ + ...facts, 'response.http.status': facts['route.attempt'].upstreamId === 'first' ? 429 : 200, + 'response.usage.billable': [{ + identity: { + model: 'm', upstream: facts['route.attempt'].upstreamId, modelKey: 'm', pricing: null, + }, quantities: { input_tokens: '10', output_tokens: '2' }, + }], + }), + }); + const pipeline = compose('failedAfterReading', [ + writeSettlement(facts => Number(facts['response.http.status']) >= 400), + failover({ failed: facts => Number(facts['response.http.status']) >= 400, owns: [] }), afterReading, source, + ]); + await expect(run(pipeline, move({ + 'serve.candidates': ['first', 'second'].map((upstreamId, candidateId) => ({ + upstreamId, candidateId, modelId: 'm', flags: [], + })), + }), { gateway: mockGatewayCtx({}), background: (promise: Promise) => { work.push(promise); } })).rejects.toBe(original); + await Promise.all(work); + expect((await repo.usage.listAll()).map(row => row.upstream).sort()).toEqual(['first', 'second']); + }); + +}); diff --git a/packages/gateway/__tests__/data-plane/rerank-pipeline_test.ts b/packages/gateway/__tests__/data-plane/rerank-pipeline_test.ts new file mode 100644 index 0000000000..137c0f528b --- /dev/null +++ b/packages/gateway/__tests__/data-plane/rerank-pipeline_test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { failover } from '../../src/data-plane/pipeline/failover.ts'; +import { rerankServePipeline } from '../../src/data-plane/rerank/pipeline.ts'; +import { mockGatewayCtx } from '../test-utils/gateway-ctx.ts'; +import { compose, defineStage, move, own, run } from '@floway-dev/pipeline'; +import type { CanonicalRerankRequest } from '@floway-dev/protocols/rerank'; + +const request: CanonicalRerankRequest = { + sourceProtocol: 'cohere-v2', + raw: {}, + query: 'what is a pipeline', + documents: ['one', 'two'], +}; + +describe('the rerank pipeline', () => { + it('assembles, and asks its caller for what the descending stages need', () => { + expect([...rerankServePipeline(request).entryNeeds].sort()).toEqual([ + 'ingress.http.headers', + 'ingress.rerank.sourceProtocol', + 'request.rerank.canonical', + 'serve.model', + ]); + }); + + it('retains only streamed bodies and restores the caller usage context', async () => { + for (const streaming of [false, true]) { + let released = 0; + const body = streaming ? own({ source: 'upstream body' }, async () => { released += 1; }) : null; + const prior = move([]); + const answer = defineStage, Record>({ + name: 'answer', + return: { provides: ['response.usage.billable', ...streaming ? ['response.http.body'] : []] }, + execute: async facts => move({ + ...facts, 'response.usage.billable': [], ...body === null ? {} : { 'response.http.body': body }, + }), + }); + const pipeline = compose, Record>('readOrStream', [ + failover({ failed: () => false, owns: streaming ? ['response.http.body'] : [] }), answer, + ]); + const { facts, drain } = await run(pipeline, move({ + 'serve.candidates': [{ upstreamId: 'upstream', modelId: 'rerank', flags: [] }], + 'serve.usage.prior': prior, + }), { gateway: mockGatewayCtx() }); + expect(facts['serve.usage.prior']).toBe(prior); + if (streaming) expect(facts['response.http.body']).toBe(body); + else expect(facts).not.toHaveProperty('response.http.body'); + expect(released).toBe(0); + await drain(); + expect(released).toBe(streaming ? 1 : 0); + } + }); + + it('names the entry key a caller did not bring, before any stage runs', async () => { + await expect(run(rerankServePipeline(request), move({ 'serve.model': 'rerank-v3' }) as never, {})) + .rejects.toThrow('run(rerankServe): rerankServe needs'); + }); +}); diff --git a/packages/gateway/__tests__/data-plane/rerank-status_test.ts b/packages/gateway/__tests__/data-plane/rerank-status_test.ts new file mode 100644 index 0000000000..641b2e0dc4 --- /dev/null +++ b/packages/gateway/__tests__/data-plane/rerank-status_test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// The two families that read their answer to the end, driven end to end. Until the review, +// neither could express a status at all: an upstream 429, a resolver's 404 and a 400 all +// reached the client as a 200 carrying an error envelope, which is not a difference the +// no-passthrough ruling asks for — declining to forward a body is not declining to forward +// a status. + +import { createCandidateRegistry } from '../../src/data-plane/pipeline/candidates.ts'; +import { enumerateModelCandidates } from '../../src/data-plane/providers/resolution.ts'; +import { rerankServePipeline } from '../../src/data-plane/rerank/pipeline.ts'; +import { initRepo } from '../../src/repo/index.ts'; +import { mockGatewayCtx } from '../test-utils/gateway-ctx.ts'; +import { stubProviderPipeline } from '../test-utils/provider-pipeline.ts'; +import { move, run } from '@floway-dev/pipeline'; +import type { CanonicalRerankRequest } from '@floway-dev/protocols/rerank'; +import { directFetcher, type ModelCandidate, type ProviderRerankCallResult } from '@floway-dev/provider'; +import { stubInternalModel, stubProvider, stubProviderModel } from '@floway-dev/test-utils'; + +vi.mock('../../src/data-plane/providers/resolution.ts', () => ({ + enumerateModelCandidates: vi.fn(), +})); + +let live: readonly ModelCandidate[] = []; + +const candidate = (upstream: string, callRerank: () => Promise): ModelCandidate => { + const endpoints = { rerank: {} }; + return { + provider: { + upstreamId: upstream, kind: 'custom', name: upstream, inboundHeaderAllowlist: [], + disabledPublicModelIds: [], modelPrefix: null, modelsCache: null, + instance: stubProvider(), + pipelines: { rerank: stubProviderPipeline('rerank', callRerank) }, + }, + model: stubInternalModel( + { id: 'rr', endpoints, providerModels: { [upstream]: stubProviderModel({ id: 'rr', endpoints, rerankTarget: { protocol: 'cohere-v2' } }) } }, + upstream, + ), + fetcher: directFetcher, + } as unknown as ModelCandidate; +}; + +const resolves = (candidates: readonly ModelCandidate[]): void => { + live = candidates; + vi.mocked(enumerateModelCandidates).mockResolvedValue({ candidates, sawModel: true, failedUpstreams: [] } as never); +}; + +const request: CanonicalRerankRequest = { + sourceProtocol: 'cohere-v2', raw: {}, query: 'q', documents: ['a', 'b'], +}; + +const serve = async () => await run( + rerankServePipeline(request), + move({ + 'ingress.rerank.sourceProtocol': 'cohere-v2', + 'ingress.http.headers': [] as readonly (readonly [string, string])[], + 'request.rerank.canonical': request, + 'serve.model': 'rr', + }) as never, + { + gateway: mockGatewayCtx({ wantsStream: false }), + background: () => {}, + ...createCandidateRegistry(), + resolveAttempt: (selector: { readonly upstreamId: string }) => { + const found = live.find(c => c.provider.upstreamId === selector.upstreamId); + if (found === undefined) throw new Error(`no live candidate for ${selector.upstreamId}`); + return found; + }, + } as never, +); + +beforeEach(() => { + initRepo({ + apiKeys: { update: async () => {} }, + usage: { record: async () => {} }, + performance: { recordNeutral: async () => {}, recordZeroOutputError: async () => {} }, + } as never); +}); + +describe('a rerank answer carries its status', () => { + it('serves a success as 200', async () => { + resolves([candidate('up_a', async () => ({ + response: Response.json({ results: [{ index: 0, relevance_score: 0.9 }], meta: { billed_units: { search_units: 1 } } }), + modelKey: 'rr-key', + target: { protocol: 'cohere-v2' }, + } as ProviderRerankCallResult))]); + const { facts } = await serve(); + expect(facts['response.http.status']).toBe(200); + }); + + // The case that reached the client as a 200 before: a refusal the client has to be able + // to act on, and a retry-after only means something alongside the status that implies it. + it('serves an upstream refusal with the upstream-s own status', async () => { + resolves([candidate('up_a', async () => ({ + response: new Response('slow down', { status: 429 }), + modelKey: 'rr-key', + target: { protocol: 'cohere-v2' }, + } as ProviderRerankCallResult))]); + const { facts } = await serve(); + expect(facts['response.http.status']).toBe(429); + }); + + it('serves the resolver-s own refusal with the status it chose', async () => { + vi.mocked(enumerateModelCandidates).mockResolvedValue({ candidates: [], sawModel: false, failedUpstreams: [] } as never); + const { facts } = await serve(); + expect(facts['response.http.status']).toBe(404); + }); +}); diff --git a/packages/gateway/__tests__/data-plane/rerank/serve_test.ts b/packages/gateway/__tests__/data-plane/rerank/serve_test.ts index 2cde6f9987..6ba389dfd2 100644 --- a/packages/gateway/__tests__/data-plane/rerank/serve_test.ts +++ b/packages/gateway/__tests__/data-plane/rerank/serve_test.ts @@ -517,7 +517,10 @@ test('cross-protocol success still validates result items before rendering', asy assertEquals(performance.errorsNoOutput, 1); }); -test('same-protocol malformed JSON is forwarded as request-only usage', async () => { +// Rerank is a JSON protocol, so a body that is not JSON at all is no answer to serve and the +// gateway says so itself rather than passing on something it never read. Forwarding those +// bytes verbatim under the upstream's 200 would report success for a turn nothing could read. +test('same-protocol answer that is not JSON is refused rather than forwarded', async () => { const { apiKey, repo } = await setupAppTest(); await saveRerankUpstream(repo, { protocol: 'jina-v1' }); @@ -529,12 +532,15 @@ test('same-protocol malformed JSON is forwarded as request-only usage', async () headers: requestHeaders(apiKey.key), body: JSON.stringify({ model: 'public-reranker', query: 'query', documents: ['one'] }), }); - assertEquals(response.status, 200); - assertEquals(await response.text(), '{not-json'); + assertEquals(response.status, 502); + const body = await response.json() as { error: { message: string } }; + assertEquals(body.error.message.includes('the rerank protocol cannot read'), true); }, ); await flushAsyncWork(); + // Called, and reported nothing this reader could make sense of — which still counts as a + // request against the upstream that was dialled. const [usage] = await repo.usage.listAll(); assertEquals(usage.requests, 1); assertEquals(usage.metrics, []); diff --git a/packages/gateway/__tests__/data-plane/search-pipeline_test.ts b/packages/gateway/__tests__/data-plane/search-pipeline_test.ts new file mode 100644 index 0000000000..79f6b1759a --- /dev/null +++ b/packages/gateway/__tests__/data-plane/search-pipeline_test.ts @@ -0,0 +1,49 @@ +// Search's pipeline, assembled. `compose` derives the entry contract and rejects an array +// that cannot work, so most of what this file establishes is established by the assembly +// succeeding at all — and what is worth writing down is the two contracts it derives, one per +// ending, plus the one it cannot derive at all. + +import { describe, expect, it } from 'vitest'; + +import { searchServePipeline } from '../../src/data-plane/alpha-search/pipeline.ts'; +import { mockGatewayCtx } from '../test-utils/gateway-ctx.ts'; +import { move, run } from '@floway-dev/pipeline'; + +const pinned = { kind: 'upstream', upstreamId: 'up_alpha', model: 'gpt-search' } as const; + +describe('the search pipeline', () => { + it('assembles the local ending, and asks its caller for what the descending stages need', () => { + expect([...searchServePipeline({ kind: 'local' }).entryNeeds].sort()).toEqual([ + 'request.search.alphaSearch', + ]); + }); + + it('requires the pinned request payload and admitted headers before entering its provider', () => { + expect([...searchServePipeline(pinned).entryNeeds].sort()).toEqual(['ingress.http.headers', 'request.search.alphaSearch']); + }); + + it('names the entry key a caller did not bring, before any stage runs', async () => { + await expect(run(searchServePipeline({ kind: 'local' }), move({}) as never, {})) + .rejects.toThrow('run(searchServe): searchServe needs request.search.alphaSearch'); + }); + + // A request with nothing to run reaches no backend at all: the parse stage answers instead + // of descending, and what the assembly guarantees — that a short-circuit covers what the + // edge needs — is what makes the run produce a rendered body, a status and a billed set + // anyway. Settlement is unconditional, so the run still writes; a search that ran locally + // simply names no billed entity. + it('answers in band when there is nothing to run, and the edge renders that answer', async () => { + const { facts } = await run( + searchServePipeline({ kind: 'local' }), + move({ 'request.search.alphaSearch': { commands: {} } }), + { gateway: mockGatewayCtx({ wantsStream: false }), background: () => {} } as never, + ); + expect(facts['response.http.status']).toBe(200); + expect(facts['response.search.rendered']).toEqual({ + encrypted_output: null, + output: 'No web search commands were provided. Populate at least one of `search_query`, `open`, or `find`.', + }); + // Nothing was called that a model prices, and an empty set is how that is said. + expect(facts['response.usage.billable']).toEqual([]); + }); +}); diff --git a/packages/gateway/__tests__/data-plane/shared/custom-ingress-header-rules_test.ts b/packages/gateway/__tests__/data-plane/shared/custom-ingress-header-rules_test.ts index 2997b90459..fac0e4a01c 100644 --- a/packages/gateway/__tests__/data-plane/shared/custom-ingress-header-rules_test.ts +++ b/packages/gateway/__tests__/data-plane/shared/custom-ingress-header-rules_test.ts @@ -165,7 +165,11 @@ const CASES: RouteCase[] = [ name: '/v1/completions reaches a Completions upstream', path: '/v1/completions', contentType: 'application/json', - body: () => JSON.stringify({ model: 'completions-model', prompt: 'hi' }), + // Asks to stream, because the upstream below answers with one. A + // non-streaming request whose upstream replies `text/event-stream` has no + // body this protocol can read, and the pipeline answers 502 rather than + // serving something it never parsed. + body: () => JSON.stringify({ model: 'completions-model', prompt: 'hi', stream: true }), upstreamPath: '/v1/completions', upstreamResponse: () => new Response( 'data: {"id":"cmpl_1","object":"text_completion","created":1,"model":"completions-model","choices":[{"index":0,"text":"ok","finish_reason":"stop"}]}\n\ndata: [DONE]\n\n', diff --git a/packages/gateway/__tests__/data-plane/shared/iterate-candidates_test.ts b/packages/gateway/__tests__/data-plane/shared/iterate-candidates_test.ts index 4c57155d14..0d05d21b96 100644 --- a/packages/gateway/__tests__/data-plane/shared/iterate-candidates_test.ts +++ b/packages/gateway/__tests__/data-plane/shared/iterate-candidates_test.ts @@ -16,7 +16,7 @@ const stubCandidate = (id: string, upstream = 'up'): ModelCandidate => inboundHeaderAllowlist: [], disabledPublicModelIds: [], modelPrefix: null, - modelsCache: null, + modelsCache: null, pipelines: {}, instance: stubProvider(), }, }); diff --git a/packages/gateway/__tests__/data-plane/shared/passthrough-attempt_test.ts b/packages/gateway/__tests__/data-plane/shared/passthrough-attempt_test.ts deleted file mode 100644 index b4e51d530c..0000000000 --- a/packages/gateway/__tests__/data-plane/shared/passthrough-attempt_test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { Hono } from 'hono'; -import { test } from 'vitest'; - -import { passthroughAttempt, type PassthroughAttemptResult } from '../../../src/data-plane/shared/passthrough-attempt.ts'; -import type { AuthVars } from '../../../src/middleware/auth.ts'; -import { mockGatewayCtx } from '../../test-utils/gateway-ctx.ts'; -import { assertEquals, assertExists, stubModelCandidate, stubProvider } from '@floway-dev/test-utils'; - -test('passthroughAttempt applies the selected provider ingress policy', async () => { - let observed: Headers | undefined; - const base = stubModelCandidate(); - const candidate = stubModelCandidate({ - provider: { - ...base.provider, - kind: 'custom', - instance: stubProvider({ - callOpenAIEmbeddings: async (_model, _body, _signal, opts) => { - observed = opts.headers; - return { response: new Response('{}'), modelKey: 'test-model' }; - }, - }), - }, - }); - const app = new Hono<{ Variables: AuthVars }>(); - app.post('/test', async c => { - await passthroughAttempt({ - c, - ctx: mockGatewayCtx(), - candidate, - operation: 'embeddings', - call: (provider, model, opts) => provider.instance.callOpenAIEmbeddings(model, { input: 'hi' }, undefined, opts), - }); - return c.text('ok'); - }); - await app.request('/test', { - method: 'POST', - headers: { - authorization: 'Bearer secret', - 'x-client-request-id': 'request-1', - 'x-debug': 'discard', - }, - }); - - assertExists(observed); - assertEquals([...observed], []); -}); - -// Drives one attempt against a canned upstream response, through the same Hono -// context the serve layer builds. -const runPassthroughAttempt = async (upstream: Response): Promise => { - const base = stubModelCandidate(); - const candidate = stubModelCandidate({ - provider: { - ...base.provider, - kind: 'custom', - instance: stubProvider({ - callOpenAIEmbeddings: async () => ({ response: upstream, modelKey: 'test-model' }), - }), - }, - }); - let result: PassthroughAttemptResult | undefined; - const app = new Hono<{ Variables: AuthVars }>(); - app.post('/test', async c => { - result = await passthroughAttempt({ - c, - ctx: mockGatewayCtx(), - candidate, - operation: 'embeddings', - call: (provider, model, opts) => provider.instance.callOpenAIEmbeddings(model, { input: 'hi' }, undefined, opts), - }); - return c.text('ok'); - }); - await app.request('/test', { method: 'POST' }); - assertExists(result); - return result; -}; - -// The fallback loop keeps only the most recent failure, so every superseded -// attempt is dropped. On the direct-connect egress a dropped response strands -// its socket, which is why a failed attempt must not carry a live body. -test('passthroughAttempt materializes a failed upstream response so a discarded attempt holds no transport', async () => { - let sourceCancelled = false; - let sourceRead = false; - const upstream = new Response( - new ReadableStream({ - start(controller) { - sourceRead = true; - controller.enqueue(new TextEncoder().encode('{"error":"upstream busy"}')); - controller.close(); - }, - cancel() { sourceCancelled = true; }, - }), - { status: 503, statusText: 'Service Unavailable', headers: { 'content-type': 'application/json', 'retry-after': '12' } }, - ); - - const result = await runPassthroughAttempt(upstream); - - assertEquals(result.status, 503); - // The upstream body was consumed rather than left for someone else to close. - assertEquals(sourceRead, true); - assertEquals(sourceCancelled, false); - assertEquals(upstream.bodyUsed, true); - // Status, headers and bytes still forward verbatim. - assertEquals(result.response.status, 503); - assertEquals(result.response.statusText, 'Service Unavailable'); - assertEquals(result.response.headers.get('retry-after'), '12'); - assertEquals(await result.response.text(), '{"error":"upstream busy"}'); -}); - -test('passthroughAttempt forwards a successful upstream response without reading it', async () => { - const upstream = new Response('ok', { status: 200 }); - const result = await runPassthroughAttempt(upstream); - - // A 2xx is returned to the caller immediately and is never discarded, so it - // must keep streaming rather than be buffered here. - assertEquals(result.response, upstream); - assertEquals(upstream.bodyUsed, false); -}); diff --git a/packages/gateway/__tests__/data-plane/shared/passthrough-serve_test.ts b/packages/gateway/__tests__/data-plane/shared/passthrough-serve_test.ts deleted file mode 100644 index 8964a07e53..0000000000 --- a/packages/gateway/__tests__/data-plane/shared/passthrough-serve_test.ts +++ /dev/null @@ -1,436 +0,0 @@ -// Behavioral coverage for the shared passthrough serve scaffold used by -// /v1/embeddings, /v1/images/{generations,edits}, and audio transcription. Each test exercises a -// full client request through the in-memory app rather than constructing a -// synthetic hono Context so the integration with model resolution, -// upstream HTTP, and background scheduling stays honest. -// -// We pick /v1/embeddings as the source under test because: -// - its acceptBinding gate is `kind === 'embedding'`, satisfied by any -// embedding-only custom upstream, with no per-endpoint Copilot setup -// required; -// - its extractBilling reads the OpenAI-style `usage.prompt_tokens` off -// a 2xx JSON body, so a body with that shape triggers a real usage -// write; -// - it shares the exact same forwardUpstreamResponse + settle -// path as the images endpoints — the behaviors under test are owned by -// passthroughServe, not the endpoint shape. - -import { test, vi } from 'vitest'; - -import { saveUpstreamForTest } from '../../repo/upstreams.ts'; -import { buildCustomUpstreamRecord, flushAsyncWork, requestAppWithWarmModels, setupAppTest } from '../../test-utils/app.ts'; -import { clearInProcessCopilotTokenCache } from '@floway-dev/provider-copilot'; -import { jsonResponse, withMockedFetch, assertEquals, assertExists } from '@floway-dev/test-utils'; - -const registerOpenAIEmbeddingsUpstream = async ( - repo: Awaited>['repo'], - ingressHeadersRules: { key: string; value: string | null }[] = [], -): Promise => { - await repo.upstreams.deleteAll(); - clearInProcessCopilotTokenCache(); - await saveUpstreamForTest(repo.upstreams, buildCustomUpstreamRecord({ - id: 'up_passthrough', - name: 'Passthrough Embedding Provider', - sortOrder: 100, - config: { - baseUrl: 'https://passthrough.example.com', - authStyle: 'bearer', - ingressHeadersRules, - apiKey: 'sk-passthrough', - endpoints: {}, - }, - })); -}; - -test('passthrough-serve: usage-record failure does not turn upstream 2xx into 502', async () => { - const { apiKey, repo } = await setupAppTest(); - await registerOpenAIEmbeddingsUpstream(repo); - - repo.usage.record = () => Promise.reject(new Error('simulated SQL write failure')); - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - try { - await withMockedFetch( - request => { - const url = new URL(request.url); - if (url.hostname === 'passthrough.example.com' && url.pathname === '/v1/models') { - return jsonResponse({ object: 'list', data: [{ id: 'custom-embed-model' }] }); - } - if (url.hostname === 'passthrough.example.com' && url.pathname === '/v1/embeddings') { - assertEquals(request.headers.get('anthropic-beta'), null); - assertEquals(request.headers.get('x-client-request-id'), null); - assertEquals(request.headers.get('x-debug'), null); - return jsonResponse({ - object: 'list', - model: 'custom-embed-model', - data: [{ object: 'embedding', index: 0, embedding: [0.5] }], - usage: { prompt_tokens: 3, total_tokens: 3 }, - }); - } - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const response = await requestAppWithWarmModels('/v1/embeddings', { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-api-key': apiKey.key, - 'anthropic-beta': 'context-1m-2025-08-07', - 'x-client-request-id': 'request-1', - 'x-debug': 'discard', - }, - body: JSON.stringify({ model: 'custom-embed-model', input: 'hi' }), - }); - - assertEquals(response.status, 200); - const body = await response.json() as { data: Array<{ embedding: number[] }> }; - assertEquals(body.data[0].embedding, [0.5]); - await flushAsyncWork(); - }, - ); - - assertEquals(errorSpy.mock.calls.some(call => call[0] === 'Failed to record usage:'), true); - } finally { - errorSpy.mockRestore(); - } -}); - -test('passthrough-serve: Custom resolves configured ingress header rules before provider dispatch', async () => { - const { apiKey, repo } = await setupAppTest(); - await registerOpenAIEmbeddingsUpstream(repo, [ - { key: 'x-passthrough', value: null }, - { key: 'x-overwrite', value: 'configured' }, - { key: 'x-empty', value: '' }, - ]); - - await withMockedFetch( - request => { - const url = new URL(request.url); - if (url.pathname === '/v1/models') { - return jsonResponse({ object: 'list', data: [{ id: 'custom-embed-model' }] }); - } - if (url.pathname === '/v1/embeddings') { - assertEquals(request.headers.get('x-passthrough'), 'client'); - assertEquals(request.headers.get('x-overwrite'), 'configured'); - assertEquals(request.headers.get('x-empty'), ''); - assertEquals(request.headers.get('authorization'), 'Bearer sk-passthrough'); - assertEquals(request.headers.get('x-unlisted'), null); - return jsonResponse({ object: 'list', data: [], model: 'custom-embed-model' }); - } - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const response = await requestAppWithWarmModels('/v1/embeddings', { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-api-key': apiKey.key, - 'x-empty': 'client', - 'x-overwrite': 'client', - 'x-passthrough': 'client', - 'x-unlisted': 'discard', - }, - body: JSON.stringify({ model: 'custom-embed-model', input: 'hi' }), - }); - assertEquals(response.status, 200); - }, - ); -}); - -test('passthrough-serve: non-JSON 2xx upstream body is forwarded verbatim with a request-only usage record', async () => { - const { apiKey, repo } = await setupAppTest(); - await registerOpenAIEmbeddingsUpstream(repo); - - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - try { - const binary = new Uint8Array([0xde, 0xad, 0xbe, 0xef]); - await withMockedFetch( - request => { - const url = new URL(request.url); - if (url.hostname === 'passthrough.example.com' && url.pathname === '/v1/models') { - return jsonResponse({ object: 'list', data: [{ id: 'custom-embed-model' }] }); - } - if (url.hostname === 'passthrough.example.com' && url.pathname === '/v1/embeddings') { - return new Response(binary, { - status: 200, - headers: { 'content-type': 'application/octet-stream' }, - }); - } - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const response = await requestAppWithWarmModels('/v1/embeddings', { - method: 'POST', - headers: { 'content-type': 'application/json', 'x-api-key': apiKey.key }, - body: JSON.stringify({ model: 'custom-embed-model', input: 'hi' }), - }); - - assertEquals(response.status, 200); - assertEquals(response.headers.get('content-type'), 'application/octet-stream'); - const bytes = new Uint8Array(await response.arrayBuffer()); - assertEquals(Array.from(bytes), Array.from(binary)); - await flushAsyncWork(); - }, - ); - - const usage = await repo.usage.listAll(); - assertEquals(usage.length, 1); - assertEquals(usage[0].requests, 1); - assertEquals(usage[0].metrics, []); - // The parse failure is observable through console.warn so operators can - // correlate missing usage rows against upstream body shape regressions. - assertEquals(warnSpy.mock.calls.some(call => typeof call[0] === 'string' && call[0].includes('passthrough-serve: failed to parse 2xx upstream body for /embeddings')), true); - } finally { - warnSpy.mockRestore(); - } -}); - -test('passthrough-serve: response header blocklist preserves vendor metadata and drops unsafe headers', async () => { - const { apiKey, repo } = await setupAppTest(); - await registerOpenAIEmbeddingsUpstream(repo); - - await withMockedFetch( - request => { - const url = new URL(request.url); - if (url.hostname === 'passthrough.example.com' && url.pathname === '/v1/models') { - return jsonResponse({ object: 'list', data: [{ id: 'custom-embed-model' }] }); - } - if (url.hostname === 'passthrough.example.com' && url.pathname === '/v1/embeddings') { - return new Response(JSON.stringify({ - object: 'list', - model: 'custom-embed-model', - data: [{ object: 'embedding', index: 0, embedding: [0.1] }], - usage: { prompt_tokens: 1, total_tokens: 1 }, - }), { - status: 200, - headers: { - 'content-type': 'application/json', - 'x-request-id': 'req-123', - 'openai-organization': 'org-1', - 'x-ratelimit-remaining': '100', - 'retry-after': '30', - 'cf-ray': 'abc', - 'x-internal-secret': 'leak', - 'set-cookie': 'nope=1', - }, - }); - } - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const response = await requestAppWithWarmModels('/v1/embeddings', { - method: 'POST', - headers: { 'content-type': 'application/json', 'x-api-key': apiKey.key }, - body: JSON.stringify({ model: 'custom-embed-model', input: 'hi' }), - }); - - assertEquals(response.status, 200); - assertExists(response.headers.get('x-request-id')); - assertEquals(response.headers.get('x-request-id'), 'req-123'); - assertEquals(response.headers.get('openai-organization'), 'org-1'); - assertEquals(response.headers.get('x-ratelimit-remaining'), '100'); - assertEquals(response.headers.get('retry-after'), '30'); - assertEquals(response.headers.get('cf-ray'), 'abc'); - assertEquals(response.headers.get('x-internal-secret'), 'leak'); - assertEquals(response.headers.get('set-cookie'), null); - await response.json(); - }, - ); -}); - -test('passthrough-serve: alias whose targets have no kind-matching binding surfaces as the regular model-missing 404', async () => { - // The inlined alias resolver walks alias targets in `selection` order and - // stops at the first target with kind-matching candidates. When every - // target is unroutable (as here, where the single target id doesn't - // exist in any upstream catalog), the resolver returns empty candidates - // + sawModel=false, and the passthrough seam surfaces the regular - // model-missing 404. No upstream call should fire. - const { apiKey, repo } = await setupAppTest(); - await registerOpenAIEmbeddingsUpstream(repo); - await repo.modelAliases.insert({ - id: 'alias_embed-fast', - name: 'embed-fast', - kind: 'embedding', - selection: 'first-available', - displayName: null, - visibleInModelsList: true, - targets: [{ target_model_id: 'unknown-embed', rules: {} }], - announcedMetadata: null, - sortOrder: 0, - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - }); - - await withMockedFetch( - request => { - const url = new URL(request.url); - if (url.hostname === 'passthrough.example.com' && url.pathname === '/v1/models') { - return jsonResponse({ object: 'list', data: [{ id: 'custom-embed-model' }] }); - } - if (url.hostname === 'passthrough.example.com' && url.pathname === '/v1/embeddings') { - throw new Error('passthrough-serve: upstream must not be called when alias has no routable target'); - } - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const response = await requestAppWithWarmModels('/v1/embeddings', { - method: 'POST', - headers: { 'content-type': 'application/json', 'x-api-key': apiKey.key }, - body: JSON.stringify({ model: 'embed-fast', input: 'hi' }), - }); - - assertEquals(response.status, 404); - const body = await response.json() as { error: { message: string; type: string } }; - assertEquals(body.error.type, 'api_error'); - // The alias name (still on `payload.model` because no candidate was - // rewritten in) reaches the wording verbatim. - assertEquals(body.error.message, 'Model embed-fast is not available on any configured upstream.'); - }, - ); -}); - -// Register two custom upstreams both exposing the same embedding model, so -// the shared narrow phase produces a two-element candidate list ordered by -// `sortOrder`. The passthrough loop must try `up_a` first (sortOrder 100) -// and `up_b` second (sortOrder 200). -const registerTwoOpenAIEmbeddingsUpstreams = async (repo: Awaited>['repo']): Promise => { - await repo.upstreams.deleteAll(); - clearInProcessCopilotTokenCache(); - await saveUpstreamForTest(repo.upstreams, buildCustomUpstreamRecord({ - id: 'up_a', name: 'Upstream A', sortOrder: 100, - config: { baseUrl: 'https://up-a.example.com', authStyle: 'bearer', apiKey: 'sk-a', endpoints: {}, ingressHeadersRules: [] }, - })); - await saveUpstreamForTest(repo.upstreams, buildCustomUpstreamRecord({ - id: 'up_b', name: 'Upstream B', sortOrder: 200, - config: { baseUrl: 'https://up-b.example.com', authStyle: 'bearer', apiKey: 'sk-b', endpoints: {}, ingressHeadersRules: [] }, - })); -}; - -test('passthrough-serve: 5xx from the first candidate falls through to the next successful upstream', async () => { - const { apiKey, repo } = await setupAppTest(); - await registerTwoOpenAIEmbeddingsUpstreams(repo); - - let firstOpenAIEmbeddingsCalls = 0; - let secondOpenAIEmbeddingsCalls = 0; - await withMockedFetch( - request => { - const url = new URL(request.url); - if (url.pathname === '/v1/models') { - return jsonResponse({ object: 'list', data: [{ id: 'custom-embed-model' }] }); - } - if (url.hostname === 'up-a.example.com' && url.pathname === '/v1/embeddings') { - firstOpenAIEmbeddingsCalls += 1; - return new Response('upstream boom', { status: 503 }); - } - if (url.hostname === 'up-b.example.com' && url.pathname === '/v1/embeddings') { - secondOpenAIEmbeddingsCalls += 1; - return jsonResponse({ - object: 'list', - model: 'custom-embed-model', - data: [{ object: 'embedding', index: 0, embedding: [0.25] }], - usage: { prompt_tokens: 2, total_tokens: 2 }, - }); - } - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const response = await requestAppWithWarmModels('/v1/embeddings', { - method: 'POST', - headers: { 'content-type': 'application/json', 'x-api-key': apiKey.key }, - body: JSON.stringify({ model: 'custom-embed-model', input: 'hi' }), - }); - - assertEquals(response.status, 200); - const body = await response.json() as { data: Array<{ embedding: number[] }> }; - assertEquals(body.data[0].embedding, [0.25]); - await flushAsyncWork(); - }, - ); - - assertEquals(firstOpenAIEmbeddingsCalls, 1); - assertEquals(secondOpenAIEmbeddingsCalls, 1); -}); - -test('passthrough-serve: when every candidate returns non-2xx the most recent upstream response is forwarded verbatim', async () => { - const { apiKey, repo } = await setupAppTest(); - await registerTwoOpenAIEmbeddingsUpstreams(repo); - - await withMockedFetch( - request => { - const url = new URL(request.url); - if (url.pathname === '/v1/models') { - return jsonResponse({ object: 'list', data: [{ id: 'custom-embed-model' }] }); - } - if (url.hostname === 'up-a.example.com' && url.pathname === '/v1/embeddings') { - return new Response('first upstream unavailable', { status: 503 }); - } - if (url.hostname === 'up-b.example.com' && url.pathname === '/v1/embeddings') { - return new Response(JSON.stringify({ error: { message: 'rate limited' } }), { - status: 429, headers: { 'content-type': 'application/json', 'retry-after': '17' }, - }); - } - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const response = await requestAppWithWarmModels('/v1/embeddings', { - method: 'POST', - headers: { 'content-type': 'application/json', 'x-api-key': apiKey.key }, - body: JSON.stringify({ model: 'custom-embed-model', input: 'hi' }), - }); - - // The last-attempted upstream's status, body, and allow-listed - // headers pass through unchanged so clients see real upstream - // telemetry — no synthetic gateway envelope. - assertEquals(response.status, 429); - assertEquals(response.headers.get('retry-after'), '17'); - const body = await response.json() as { error: { message: string } }; - assertEquals(body.error.message, 'rate limited'); - await flushAsyncWork(); - }, - ); -}); - -// A throw during candidate rollover attributes the error row to the -// throwing candidate, not the previously-succeeded one. -test('passthrough-serve: throw during rollover attributes the error perf row to the throwing candidate, not the previous one', async () => { - const { apiKey, repo } = await setupAppTest(); - await registerTwoOpenAIEmbeddingsUpstreams(repo); - - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - try { - await withMockedFetch( - request => { - const url = new URL(request.url); - if (url.pathname === '/v1/models') { - return jsonResponse({ object: 'list', data: [{ id: 'custom-embed-model' }] }); - } - if (url.hostname === 'up-a.example.com' && url.pathname === '/v1/embeddings') { - return new Response('first upstream unavailable', { status: 503 }); - } - if (url.hostname === 'up-b.example.com' && url.pathname === '/v1/embeddings') { - throw new Error('simulated network error to up_b'); - } - throw new Error(`Unhandled fetch ${request.url}`); - }, - async () => { - const response = await requestAppWithWarmModels('/v1/embeddings', { - method: 'POST', - headers: { 'content-type': 'application/json', 'x-api-key': apiKey.key }, - body: JSON.stringify({ model: 'custom-embed-model', input: 'hi' }), - }); - - assertEquals(response.status, 502); - await flushAsyncWork(); - }, - ); - - const perfRows = await repo.performance.listAll(); - const errorRows = perfRows.filter(row => row.errorsNoOutput + row.errorsWithOutput > 0); - assertEquals(errorRows.length, 1); - assertEquals(errorRows[0].upstream, 'up_b'); - } finally { - errorSpy.mockRestore(); - } -}); diff --git a/packages/gateway/__tests__/data-plane/tools/web-search/alpha-search/relay-response_test.ts b/packages/gateway/__tests__/data-plane/tools/web-search/alpha-search/relay-response_test.ts deleted file mode 100644 index 5e601b11e6..0000000000 --- a/packages/gateway/__tests__/data-plane/tools/web-search/alpha-search/relay-response_test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { relayFetchedResponse } from '../../../../../src/data-plane/tools/web-search/alpha-search/relay-response.ts'; - -describe('relayFetchedResponse', () => { - it('keeps decoded bytes and representation-safe upstream headers', async () => { - const body = JSON.stringify({ output: 'decoded' }); - const upstream = new Response(body, { - status: 202, - statusText: 'Accepted upstream', - headers: { - connection: 'close', - 'content-encoding': 'gzip', - 'content-length': '999', - 'content-type': 'application/json', - 'set-cookie': 'session=upstream-secret', - 'transfer-encoding': 'chunked', - 'x-oai-request-id': 'req_search_1', - }, - }); - - const relayed = relayFetchedResponse(upstream); - - expect(relayed.status).toBe(202); - expect(relayed.statusText).toBe('Accepted upstream'); - expect(await relayed.text()).toBe(body); - expect(relayed.headers.get('content-type')).toBe('application/json'); - expect(relayed.headers.get('x-oai-request-id')).toBe('req_search_1'); - expect(relayed.headers.get('connection')).toBeNull(); - expect(relayed.headers.get('content-encoding')).toBeNull(); - expect(relayed.headers.get('content-length')).toBeNull(); - expect(relayed.headers.get('set-cookie')).toBeNull(); - expect(relayed.headers.get('transfer-encoding')).toBeNull(); - }); -}); diff --git a/packages/gateway/__tests__/dump/run-sink_test.ts b/packages/gateway/__tests__/dump/run-sink_test.ts new file mode 100644 index 0000000000..962e68ac87 --- /dev/null +++ b/packages/gateway/__tests__/dump/run-sink_test.ts @@ -0,0 +1,206 @@ +import { expect, test } from 'vitest'; + +import { installDumpStubs } from './test-fixtures.ts'; +import { initDumpBroker, initDumpStore } from '../../src/dump/registry.ts'; +import { openRunDump } from '../../src/dump/run-sink.ts'; +import type { StoredDumpRecord, StoredDumpRunRecord } from '../../src/dump/types.ts'; +import type { ApiKey } from '../../src/repo/types.ts'; +import { flushBackground, trackBackground } from '../test-utils/background-tracker.ts'; +import { compose, defineStage, defer, move, run, type DumpEvent, type Event } from '@floway-dev/pipeline'; +import { assertEquals } from '@floway-dev/test-utils'; + +const apiKey = (dumpRetentionSeconds: number | null): ApiKey => ({ + id: 'key_run', + userId: 1, + name: 'Run key', + key: 'raw-run-key', + serverSecret: '11'.repeat(32), + createdAt: '2026-01-01T00:00:00.000Z', + upstreamIds: null, + randomizeUpstreams: false, + deletedAt: null, + dumpRetentionSeconds, + openaiResponsesRetentionSeconds: 0, +}); + +const requestBody = { bytes: new TextEncoder().encode('{"input":"hi"}'), streamError: null }; + +const turn = { method: 'POST', path: '/v1/embeddings', body: requestBody, headers: [] }; + +// Two stages, so the record has a shape to hold: one that hands down and one +// that answers. +interface Facts { + 'in.text': string; + 'out.result': string; +} + +const answer = defineStage, Pick>({ + name: 'answer', + return: { provides: ['out.result'] }, + execute: async facts => move({ ...facts, 'out.result': facts['in.text'].toUpperCase() }), +}); + +const shout = defineStage, Pick, Pick, Pick>({ + name: 'shout', + through: { + request: { needs: ['in.text'], consumes: [], provides: [] }, + response: { needs: ['out.result'], consumes: [], provides: [] }, + }, + execute: async (facts, next, use) => { + await use.log.info('shouting', { length: facts['in.text'].length }); + return await next(move({ ...facts, 'in.text': `${facts['in.text']}!` })); + }, +}); + +const pipeline = compose, Pick>('shout-it', [shout, answer]); + +const runRecordOf = (stored: { record: StoredDumpRecord } | undefined): StoredDumpRunRecord => { + if (!stored) throw new Error('expected a stored dump record'); + if (stored.record.shape !== 'run') throw new Error(`expected the run shape, got ${stored.record.shape}`); + return stored.record; +}; + +const ndjson = (record: StoredDumpRunRecord): string => new TextDecoder().decode(record.events); + +const lines = (record: StoredDumpRunRecord): DumpEvent[] => + ndjson(record).split('\n').filter(Boolean).map(line => JSON.parse(line) as DumpEvent); + +test('a run under a key with retention stores its whole event stream as NDJSON', async () => { + const stubs = installDumpStubs(initDumpStore, initDumpBroker); + const dump = openRunDump(apiKey(3600), turn, trackBackground, false, { upstreamCallStartedAt: null, firstOutputTokenAt: null }); + if (dump === null) throw new Error('a key with retention must open a run dump'); + + const { facts } = await run(pipeline, move({ 'in.text': 'hey' }), { dump: dump.sink }); + assertEquals(facts['out.result'], 'HEY!'); + dump.finalize(200, 12); + await flushBackground(); + + const record = runRecordOf(stubs.stored[0]); + const events = lines(record); + // Every stage is in the tree and the shape of the run is in the parent ids. + assertEquals( + events.filter(event => event.type === 'stage.entered').map(event => [event.name, event.parentStageId]), + [['shout', null], ['answer', 1]], + ); + // A stage's own log line is content about that stage, like the other five kinds. + assertEquals(events.filter(event => event.type === 'stage.log').map(event => event.message), ['shouting']); + // A `stage.leaved` that hands up exactly what its last child handed up carries + // nothing and is not emitted, so `shout`'s exit goes and `answer`'s stays. + assertEquals(events.filter(event => event.type === 'stage.leaved').map(event => event.stageId), [2]); + // NDJSON: one event per line, appended in order, so the stored file and what a + // live observer would be handed are the same bytes. + assertEquals(ndjson(record).endsWith('\n'), true); + assertEquals(ndjson(record).trimEnd().split('\n').length, events.length); + + assertEquals(record.meta.method, 'POST'); + assertEquals(record.meta.path, '/v1/embeddings'); + assertEquals(record.meta.status, 200); + assertEquals(record.meta.requestBytes, requestBody.bytes.byteLength); + assertEquals(record.meta.responseBytes, 12); + assertEquals(stubs.published.map(entry => entry.meta.id), [record.meta.id]); +}); + +test('a key without retention opens no sink, so a run records and stores nothing', async () => { + const stubs = installDumpStubs(initDumpStore, initDumpBroker); + const dump = openRunDump(apiKey(null), turn, trackBackground, false, { upstreamCallStartedAt: null, firstOutputTokenAt: null }); + assertEquals(dump, null); + + // The absence is the mechanism: with nothing to put in `services.dump` the + // runner does none of the recording, rather than feeding a sink that throws + // the result away. + const emitted: Event[] = []; + const services = dump === null ? {} : { dump: (event: Event) => { emitted.push(event); } }; + const { facts } = await run(pipeline, move({ 'in.text': 'hey' }), services); + assertEquals(facts['out.result'], 'HEY!'); + await flushBackground(); + + assertEquals(emitted, []); + assertEquals(stubs.stored, []); + assertEquals(stubs.published, []); +}); + +test('a run record carries the attribution the turn stamped on it', async () => { + const stubs = installDumpStubs(initDumpStore, initDumpBroker); + const dump = openRunDump(apiKey(3600), turn, trackBackground, false, { upstreamCallStartedAt: null, firstOutputTokenAt: null }); + if (dump === null) throw new Error('a key with retention must open a run dump'); + + dump.requestedModel('text-embedding-3-small'); + dump.failed(new Error('upstream went\naway')); + dump.finalize(null, 0); + await flushBackground(); + + const record = runRecordOf(stubs.stored[0]); + assertEquals(record.meta.model, 'text-embedding-3-small'); + assertEquals(record.meta.status, null); + assertEquals(record.meta.error, { kind: 'failed', reason: 'upstream went away' }); +}); + +test('a run whose request never arrived intact records that as the turn\'s failure', async () => { + const stubs = installDumpStubs(initDumpStore, initDumpBroker); + const dump = openRunDump( + apiKey(3600), + { ...turn, body: { bytes: new Uint8Array(), streamError: 'client aborted the upload' } }, + trackBackground, + false, + { upstreamCallStartedAt: null, firstOutputTokenAt: null }, + ); + if (dump === null) throw new Error('a key with retention must open a run dump'); + + dump.finalize(400, 0); + await flushBackground(); + + assertEquals(runRecordOf(stubs.stored[0]).meta.error, { kind: 'failed', reason: 'client aborted the upload' }); +}); + +test('finalizing on a response measures what the client reads and leaves it intact', async () => { + const stubs = installDumpStubs(initDumpStore, initDumpBroker); + const dump = openRunDump(apiKey(3600), turn, trackBackground, false, { upstreamCallStartedAt: null, firstOutputTokenAt: null }); + if (dump === null) throw new Error('a key with retention must open a run dump'); + + await run(pipeline, move({ 'in.text': 'hey' }), { dump: dump.sink }); + const answered = dump.finalize(new Response('data: one\n\ndata: two\n\n', { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + })); + + assertEquals(await answered.text(), 'data: one\n\ndata: two\n\n'); + await flushBackground(); + + const record = runRecordOf(stubs.stored[0]); + assertEquals(record.meta.responseBytes, 22); + +}); + +test('a run reads the stable timing state at completion alongside its stage history', async () => { + const stubs = installDumpStubs(initDumpStore, initDumpBroker); + const timing = { upstreamCallStartedAt: 100, firstOutputTokenAt: null as number | null }; + const dump = openRunDump(apiKey(3600), turn, trackBackground, true, timing); + if (dump === null) throw new Error('a key with retention must open a run dump'); + await run(pipeline, move({ 'in.text': 'hey' }), { dump: dump.sink }); + timing.firstOutputTokenAt = 225; + assertEquals(await dump.finalize(new Response('client')).text(), 'client'); + await flushBackground(); + const record = runRecordOf(stubs.stored[0]); + assertEquals(record.meta.ttftMs, 125); + assertEquals('capture' in record, false); + assertEquals(lines(record).some(event => event.type === 'stage.entered'), true); +}); + +test('closing waits for deferred outcomes and persists their settlement event', async () => { + const stubs = installDumpStubs(initDumpStore, initDumpBroker); + const result = Promise.withResolvers(); + const deferred = defer(result.promise); + const terminal = defineStage, { pending: typeof deferred }>({ + name: 'pending', return: { provides: ['pending'] }, execute: async () => move({ pending: deferred }), + }); + const dump = openRunDump(apiKey(3600), turn, trackBackground, false, { upstreamCallStartedAt: null, firstOutputTokenAt: null })!; + const { drain } = await run(compose('deferred', [terminal]), move({}), { dump: dump.sink }); + dump.afterRun(drain); + dump.finalize(200, 0); + await Promise.resolve(); + expect(stubs.stored).toHaveLength(0); + result.resolve('late value'); + await flushBackground(); + expect(lines(runRecordOf(stubs.stored[0])).some(event => event.type === 'deferred.settled')).toBe(true); + expect(ndjson(runRecordOf(stubs.stored[0]))).toContain('late value'); +}); diff --git a/packages/gateway/__tests__/dump/test-fixtures.ts b/packages/gateway/__tests__/dump/test-fixtures.ts index 6cbb022c2b..4ed29e0d12 100644 --- a/packages/gateway/__tests__/dump/test-fixtures.ts +++ b/packages/gateway/__tests__/dump/test-fixtures.ts @@ -1,6 +1,7 @@ import type { DumpStore } from '../../src/dump/store-contract.ts'; -import type { DumpMetadata, StoredDumpRecord } from '../../src/dump/types.ts'; -import type { DumpBroker } from '@floway-dev/dump'; +import type { StoredDumpEdgeRecord, StoredDumpRecord, StoredDumpRunRecord } from '../../src/dump/types.ts'; +import type { DumpBroker, DumpMetadata } from '@floway-dev/dump'; +import { encodeRun, toNdjson, type Event } from '@floway-dev/pipeline'; export const fakeMeta = (overrides: Partial = {}): DumpMetadata => ({ id: 'test-id', @@ -20,12 +21,41 @@ export const fakeMeta = (overrides: Partial = {}): DumpMetadata => ...overrides, }); -export const fakeRecord = (overrides: Partial = {}): StoredDumpRecord => ({ +export const fakeRecord = (overrides: Partial = {}): StoredDumpEdgeRecord => ({ + shape: 'edge', meta: fakeMeta(overrides), request: { method: 'POST', path: '/v1/x', headers: [], body: new Uint8Array() }, response: { status: 200, headers: [], body: { type: 'none' } }, }); +// A run record holds one NDJSON stream and no edge halves, so a fixture takes +// the events a run emitted and encodes them the way the sink does — object +// space, folding and all. +export const fakeRunRecord = (events: readonly Event[], overrides: Partial = {}): StoredDumpRunRecord => ({ + shape: 'run', + meta: fakeMeta(overrides), + events: new TextEncoder().encode(toNdjson(encodeRun(events))), +}); + +// A reader hands back whichever shape was written, so a test asserting on the +// request or response halves says which one it wrote. +export const edgeRecordOf = (record: StoredDumpRecord | null | undefined): StoredDumpEdgeRecord => { + if (!record) throw new Error('expected a stored dump record'); + if (record.shape !== 'edge') throw new Error(`expected the edge shape, got ${record.shape}`); + return record; +}; + +export const runRecordOf = (record: StoredDumpRecord | null | undefined): StoredDumpRunRecord => { + if (!record) throw new Error('expected a stored dump record'); + if (record.shape !== 'run') throw new Error(`expected the run shape, got ${record.shape}`); + return record; +}; + +/** The events a run recorded, decoded from the NDJSON one line at a time. */ +export const eventsOf = (record: StoredDumpRunRecord): readonly Record[] => + new TextDecoder().decode(record.events).split('\n').filter(line => line.length > 0) + .map(line => JSON.parse(line) as Record); + type DumpStubFailMethod = | 'put' | 'list' @@ -60,11 +90,14 @@ export const installDumpStubs = ( }, async put(keyId, record) { if (throws.put) throw throws.put; - if (record.request.body.encoding !== 'identity') throw new Error('dump test stub expected identity request body'); - const storedRecord: StoredDumpRecord = { - ...record, - request: { ...record.request, body: record.request.body.bytes }, - }; + // The run shape is already stored-shaped; only the edge shape carries a + // separately prepared request body to rehydrate. + if (record.shape === 'edge' && record.request.body.encoding !== 'identity') { + throw new Error('dump test stub expected identity request body'); + } + const storedRecord: StoredDumpRecord = record.shape === 'run' + ? record + : { ...record, request: { ...record.request, body: record.request.body.bytes } }; stored.push({ keyId, record: storedRecord }); const list = records.get(keyId) ?? []; list.unshift(storedRecord); diff --git a/packages/gateway/__tests__/dump/wire_test.ts b/packages/gateway/__tests__/dump/wire_test.ts index 9850f862b5..c1e6872ef6 100644 --- a/packages/gateway/__tests__/dump/wire_test.ts +++ b/packages/gateway/__tests__/dump/wire_test.ts @@ -1,10 +1,11 @@ -import { test } from 'vitest'; +import { expect, test } from 'vitest'; -import type { StoredDumpRecord } from '../../src/dump/types.ts'; +import type { DumpEdgeRecord, StoredDumpEdgeRecord } from '../../src/dump/types.ts'; import { dumpRecordToWire } from '../../src/dump/wire.ts'; import { assertEquals } from '@floway-dev/test-utils'; -const baseStored = (overrides: Partial = {}): StoredDumpRecord => ({ +const baseStored = (overrides: Partial = {}): StoredDumpEdgeRecord => ({ + shape: 'edge', meta: { id: 'rec', startedAt: 0, @@ -26,10 +27,16 @@ const baseStored = (overrides: Partial = {}): StoredDumpRecord ...overrides, }); +const edgeWire = (record: StoredDumpEdgeRecord): DumpEdgeRecord => { + const wire = dumpRecordToWire(record); + if (wire.shape !== 'edge') throw new Error('expected the edge shape'); + return wire; +}; + // A textual request content-type with valid UTF-8 bytes serializes as utf8 on // the wire — the dashboard reads `data` directly as a string. test('dumpRecordToWire encodes a textual request body as utf8', () => { - const wire = dumpRecordToWire(baseStored({ + const wire = edgeWire(baseStored({ request: { method: 'POST', path: '/v1/messages', @@ -42,7 +49,7 @@ test('dumpRecordToWire encodes a textual request body as utf8', () => { }); test('dumpRecordToWire recognizes structured textual suffixes without accepting near matches', () => { - const structured = dumpRecordToWire(baseStored({ + const structured = edgeWire(baseStored({ request: { method: 'POST', path: '/v1/x', @@ -52,7 +59,7 @@ test('dumpRecordToWire recognizes structured textual suffixes without accepting })); assertEquals(structured.request.body.encoding, 'utf8'); - const nearMatch = dumpRecordToWire(baseStored({ + const nearMatch = edgeWire(baseStored({ request: { method: 'POST', path: '/v1/x', @@ -67,7 +74,7 @@ test('dumpRecordToWire recognizes structured textual suffixes without accepting // preserves every byte; the dashboard decodes base64 client-side. test('dumpRecordToWire encodes a binary response body as base64', () => { const png = new Uint8Array([0x89, 0x50, 0x4E, 0x47]); // PNG magic - const wire = dumpRecordToWire(baseStored({ + const wire = edgeWire(baseStored({ response: { status: 200, headers: [['content-type', 'image/png']], @@ -85,7 +92,7 @@ test('dumpRecordToWire encodes a binary response body as base64', () => { // as UTF-8 falls through to base64 so the wire never silently corrupts. test('dumpRecordToWire falls back to base64 when textual content-type carries non-UTF-8 bytes', () => { const bytes = new Uint8Array([0xFF, 0xFE, 0xFD]); - const wire = dumpRecordToWire(baseStored({ + const wire = edgeWire(baseStored({ request: { method: 'POST', path: '/v1/x', @@ -101,22 +108,48 @@ test('dumpRecordToWire falls back to base64 when textual content-type carries no // `stream` and `none` response bodies pass through wire serialization // unchanged because they carry no raw bytes. test('dumpRecordToWire passes stream + none response bodies through', () => { - const streamWire = dumpRecordToWire(baseStored({ + const streamWire = edgeWire(baseStored({ response: { status: 200, headers: [], body: { type: 'stream', events: [] } }, })); assertEquals(streamWire.response.body.type, 'stream'); - const noneWire = dumpRecordToWire(baseStored({ + const noneWire = edgeWire(baseStored({ response: { status: null, headers: [], body: { type: 'none' } }, })); assertEquals(noneWire.response.body.type, 'none'); }); +// A run record crosses to the wire as the stored bytes decoded, because a line +// of NDJSON is one event and one SSE `data:` payload — what the dashboard reads +// here is what a live observer will read frame by frame. +test('dumpRecordToWire hands a run record its NDJSON verbatim', () => { + const ndjson = '{"type":"stage.entered","stageId":1,"name":"serve","parentStageId":null}\n' + + '{"type":"stage.leaved","stageId":1,"facts":{"response.http.status":200}}\n'; + const wire = dumpRecordToWire({ + shape: 'run', + meta: baseStored().meta, + events: new TextEncoder().encode(ndjson), + }); + if (wire.shape !== 'run') throw new Error('expected the run shape'); + assertEquals(wire.events, ndjson); + assertEquals(wire.meta.id, 'rec'); +}); + +// The gateway wrote those bytes itself, so bytes that are not UTF-8 mean a +// corrupted record and the reader says so rather than serving mojibake. +test('dumpRecordToWire refuses a run stream that is not UTF-8', () => { + expect(() => dumpRecordToWire({ + shape: 'run', + meta: baseStored().meta, + events: new Uint8Array([0xFF, 0xFE, 0xFD]), + })).toThrow(); +}); + // The optional parallel `upstream` body (pre-translation view) round-trips // through the wire with the same bytes/stream semantics as the downstream // body. Absent on native turns and old records — `upstream` stays undefined. test('dumpRecordToWire maps the upstream stream body when present', () => { - const wire = dumpRecordToWire(baseStored({ + const wire = edgeWire(baseStored({ meta: { ...baseStored().meta, targetApi: 'openaiResponses' }, response: { status: 200, @@ -134,7 +167,7 @@ test('dumpRecordToWire maps the upstream stream body when present', () => { }); test('dumpRecordToWire redacts upstream credentials from exchange and legacy headers', () => { - const wire = dumpRecordToWire(baseStored({ + const wire = edgeWire(baseStored({ capture: { exchanges: [{ upstreamId: 'u', @@ -163,7 +196,7 @@ test('dumpRecordToWire redacts upstream credentials from exchange and legacy hea }); test('dumpRecordToWire maps the upstream bytes body using its own content-type', () => { - const wire = dumpRecordToWire(baseStored({ + const wire = edgeWire(baseStored({ meta: { ...baseStored().meta, targetApi: 'openaiChatCompletions' }, response: { status: 200, @@ -184,7 +217,7 @@ test('dumpRecordToWire maps the upstream bytes body using its own content-type', }); test('dumpRecordToWire omits upstream when absent (native turn / old record)', () => { - const wire = dumpRecordToWire(baseStored({ + const wire = edgeWire(baseStored({ response: { status: 200, headers: [], body: { type: 'none' } }, })); assertEquals(wire.response.upstream, undefined); diff --git a/packages/gateway/__tests__/repo/dump-store_test.ts b/packages/gateway/__tests__/repo/dump-store_test.ts index 90a3a10923..38b60010c2 100644 --- a/packages/gateway/__tests__/repo/dump-store_test.ts +++ b/packages/gateway/__tests__/repo/dump-store_test.ts @@ -6,11 +6,12 @@ import { expect, test } from 'vitest'; import { createSqliteTestDb, mapRunChangeCount } from './test-sqlite.ts'; import { decodeDumpBodyDescriptor } from '../../src/dump/storage-codec.ts'; -import type { DumpWriteRecord } from '../../src/dump/types.ts'; +import type { DumpWriteEdgeRecord, StoredDumpEdgeRecord, StoredDumpRecord, StoredDumpRunRecord } from '../../src/dump/types.ts'; import { FileDumpStore } from '../../src/repo/dump-store.ts'; import { initRepo } from '../../src/repo/index.ts'; import { SqlRepo } from '../../src/repo/sql.ts'; import { collectSpilledFiles } from '../../src/scheduled/spilled-files.ts'; +import { encodeRun, toNdjson, type Facts } from '@floway-dev/pipeline'; import { initFileStore, MemoryFileStore } from '@floway-dev/platform'; import type { FileStore, SqlDatabase } from '@floway-dev/platform'; import { assertEquals, assertExists } from '@floway-dev/test-utils'; @@ -37,7 +38,8 @@ const utf8 = (s: string): Uint8Array => new TextEncoder().encode(s); const requestBody = utf8('{"hello":"world"}'); -const baseRecord = (id: string, completedAt: number): DumpWriteRecord => ({ +const baseRecord = (id: string, completedAt: number): DumpWriteEdgeRecord => ({ + shape: 'edge', meta: { id, startedAt: completedAt - 1, completedAt, method: 'POST', path: '/v1/x', status: 200, upstream: null, model: 'm', inputTokens: 1, outputTokens: 2, @@ -55,6 +57,14 @@ const baseRecord = (id: string, completedAt: number): DumpWriteRecord => ({ }, }); +// Every fixture above writes the edge shape, and a reader hands back what was +// written — so a test that asserts on the request or response halves says so. +const edgeOf = (record: StoredDumpRecord | null): StoredDumpEdgeRecord => { + assertExists(record); + if (record.shape !== 'edge') throw new Error('expected the edge shape'); + return record; +}; + test('FileDumpStore prepares request gzip before terminal persistence', async () => { const db = await openDb(); const files = new MemoryFileStore(); @@ -62,7 +72,7 @@ test('FileDumpStore prepares request gzip before terminal persistence', async () const raw = utf8(`{"content":"${'repeatable '.repeat(4096)}"}`); const prepared = await store.prepareRequestBody(raw); const base = baseRecord('01HZZ0000000000000000000P1', Date.UTC(2026, 5, 1, 12, 0, 0)); - const record: DumpWriteRecord = { + const record: DumpWriteEdgeRecord = { ...base, meta: { ...base.meta, requestBytes: raw.byteLength }, request: { @@ -77,8 +87,7 @@ test('FileDumpStore prepares request gzip before terminal persistence', async () assertEquals(prepared.decodedByteLength, raw.byteLength); assertEquals(prepared.bytes.byteLength < raw.byteLength, true); await store.put('key_x', record); - const fetched = await store.get('key_x', record.meta.id); - assertExists(fetched); + const fetched = edgeOf(await store.get('key_x', record.meta.id)); assertEquals(Array.from(fetched.request.body), Array.from(raw)); }); @@ -89,8 +98,7 @@ test('FileDumpStore round-trips a JSON record through gzip', async () => { const record = baseRecord('01HZZ0000000000000000000A1', Date.UTC(2026, 5, 1, 12, 0, 0)); await store.put('key_x', record); - const fetched = await store.get('key_x', '01HZZ0000000000000000000A1'); - assertExists(fetched); + const fetched = edgeOf(await store.get('key_x', '01HZZ0000000000000000000A1')); assertEquals(fetched.meta.id, record.meta.id); assertEquals(new TextDecoder().decode(fetched.request.body), '{"hello":"world"}'); if (fetched.response.body.type !== 'bytes') throw new Error('expected bytes'); @@ -102,7 +110,7 @@ test('FileDumpStore round-trips ttftMs on persisted records', async () => { const files = new MemoryFileStore(); const store = new FileDumpStore(db, files); const base = baseRecord('01HZZ0000000000000000000TT', Date.UTC(2026, 5, 1, 12, 0, 0)); - const record: DumpWriteRecord = { + const record: DumpWriteEdgeRecord = { ...base, meta: { ...base.meta, ttftMs: 250 }, }; @@ -123,7 +131,7 @@ test('FileDumpStore preserves the original content-type header on binary bodies' const files = new MemoryFileStore(); const store = new FileDumpStore(db, files); const pngMagic = new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); - const record: DumpWriteRecord = { + const record: DumpWriteEdgeRecord = { ...baseRecord('01HZZ0000000000000000000PNG', Date.UTC(2026, 5, 1, 12, 0, 0)), response: { status: 200, @@ -133,8 +141,7 @@ test('FileDumpStore preserves the original content-type header on binary bodies' }; await store.put('key_x', record); - const fetched = await store.get('key_x', '01HZZ0000000000000000000PNG'); - assertExists(fetched); + const fetched = edgeOf(await store.get('key_x', '01HZZ0000000000000000000PNG')); // The header pair must survive verbatim — no `;base64` suffix tacked on. assertEquals(fetched.response.headers.find(([k]) => k === 'content-type')?.[1], 'image/png'); if (fetched.response.body.type !== 'bytes') throw new Error('expected bytes'); @@ -148,7 +155,7 @@ test('FileDumpStore preserves the bytes discriminator on an empty-body response' // 204-style: real upstream response with status + headers but a zero-length // body. Persistence drops the body file (nothing to gzip), but headers are // still written — the read path must surface this as `bytes`, not `none`. - const record: DumpWriteRecord = { + const record: DumpWriteEdgeRecord = { ...baseRecord('01HZZ0000000000000000000E1', Date.UTC(2026, 5, 1, 12, 0, 0)), response: { status: 204, @@ -158,8 +165,7 @@ test('FileDumpStore preserves the bytes discriminator on an empty-body response' }; await store.put('key_x', record); - const fetched = await store.get('key_x', '01HZZ0000000000000000000E1'); - assertExists(fetched); + const fetched = edgeOf(await store.get('key_x', '01HZZ0000000000000000000E1')); if (fetched.response.body.type !== 'bytes') throw new Error('expected bytes'); assertEquals(fetched.response.body.body.byteLength, 0); assertEquals(fetched.response.headers.find(([k]) => k === 'content-type')?.[1], 'application/json'); @@ -169,7 +175,7 @@ test('FileDumpStore round-trips an SSE record as a stream events array', async ( const db = await openDb(); const files = new MemoryFileStore(); const store = new FileDumpStore(db, files); - const record: DumpWriteRecord = { + const record: DumpWriteEdgeRecord = { ...baseRecord('01HZZ0000000000000000000A2', Date.UTC(2026, 5, 1, 12, 0, 0)), response: { status: 200, @@ -184,13 +190,98 @@ test('FileDumpStore round-trips an SSE record as a stream events array', async ( }, }; await store.put('key_x', record); - const fetched = await store.get('key_x', '01HZZ0000000000000000000A2'); - assertExists(fetched); + const fetched = edgeOf(await store.get('key_x', '01HZZ0000000000000000000A2')); if (fetched.response.body.type !== 'stream') throw new Error('expected stream'); assertEquals(fetched.response.body.events.length, 2); assertEquals(fetched.response.body.events[0]!.frame.type, 'event'); }); +// A pipelined turn stores the run rather than its edges: one NDJSON body file +// under the same contract, and a row that carries the same metadata. +const runRecord = (id: string, completedAt: number): StoredDumpRunRecord => { + const entered: Facts = { 'request.http.path': '/v1/embeddings' }; + const left: Facts = { ...entered, 'response.http.status': 200 }; + return { + shape: 'run', + meta: baseRecord(id, completedAt).meta, + events: new TextEncoder().encode(toNdjson(encodeRun([ + { type: 'stage.entered', stageId: 1, name: 'serve', parentStageId: null, facts: entered }, + { type: 'stage.entered', stageId: 2, name: 'dial', parentStageId: 1, facts: entered }, + { type: 'stage.leaved', stageId: 2, facts: left }, + { type: 'stage.leaved', stageId: 1, facts: left }, + ]))), + }; +}; + +test('FileDumpStore round-trips a run record as its NDJSON event stream', async () => { + const db = await openDb(); + const files = new MemoryFileStore(); + const store = new FileDumpStore(db, files); + const record = runRecord('01HZZ00000000000000000RUN1', Date.UTC(2026, 5, 1, 12, 0, 0)); + + await store.put('key_x', record); + const fetched = await store.get('key_x', record.meta.id); + assertExists(fetched); + if (fetched.shape !== 'run') throw new Error(`expected the run shape, got ${fetched.shape}`); + assertEquals(new TextDecoder().decode(fetched.events), new TextDecoder().decode(record.events)); + + // The stream is a body file like the other two: gzipped, named for what it + // holds, and pointed at by the row's descriptor. + const row = await db.prepare('SELECT request_body_descriptor, response_body_descriptor FROM dump_records WHERE key_id = ? AND id = ?') + .bind('key_x', record.meta.id) + .first<{ request_body_descriptor: string | null; response_body_descriptor: string }>(); + assertExists(row); + assertEquals(row.request_body_descriptor, null); + const descriptor = decodeDumpBodyDescriptor(row.response_body_descriptor, 'test run descriptor'); + assertEquals(descriptor.type, 'run'); + assertEquals(descriptor.key.endsWith('.run.gz'), true); +}); + +test('FileDumpStore lists a run record alongside an edge-shaped one', async () => { + const db = await openDb(); + const store = new FileDumpStore(db, new MemoryFileStore()); + const base = Date.UTC(2026, 5, 1, 12, 0, 0); + await store.put('key_x', baseRecord('01HZZ00000000000000000EDG1', base)); + await store.put('key_x', runRecord('01HZZ00000000000000000RUN2', base + 1)); + + const listed = await store.list('key_x', { limit: 10 }); + assertEquals(listed.map(meta => meta.id), ['01HZZ00000000000000000RUN2', '01HZZ00000000000000000EDG1']); + // One metadata shape for both: the list says nothing about how a turn was + // served, and every column a row renders is filled either way. + assertEquals(listed.map(meta => meta.model), ['m', 'm']); + assertEquals(listed.map(meta => meta.status), [200, 200]); +}); + +test('FileDumpStore retires an expired run record and collects its stream file', async () => { + const db = await openDb(); + const repo = new SqlRepo(db); + initRepo(repo); + const files = new MemoryFileStore(); + initFileStore(files); + const store = new FileDumpStore(db, files); + const now = Date.UTC(2026, 5, 1, 12, 0, 0); + await store.put('key_x', runRecord('01HZZ00000000000000000RUN3', Date.UTC(2026, 5, 1, 9, 0, 0))); + await store.put('key_x', runRecord('01HZZ00000000000000000RUN4', now)); + await repo.apiKeys.update('key_x', { dumpRetentionSeconds: 2 * 3600 }); + + const originalNow = Date.now; + Date.now = () => now + 1; + try { + assertEquals((await store.list('key_x', { limit: 10 })).map(meta => meta.id), ['01HZZ00000000000000000RUN4']); + assertEquals(await store.get('key_x', '01HZZ00000000000000000RUN3'), null); + assertEquals(await store.deleteExpiredBatch('key_x', now + 1, 100), 1); + await collectSpilledFiles(now + 1); + } finally { + Date.now = originalNow; + } + + const { results: remaining } = await db + .prepare("SELECT file_key FROM spilled_files WHERE state = 'owned' ORDER BY file_key") + .all<{ file_key: string }>(); + assertEquals(remaining.map(row => row.file_key.endsWith('.run.gz')), [true]); + assertEquals(remaining.every(row => !row.file_key.includes('2026060109')), true); +}); + // The optional pre-translation upstream body spills to its own file under the // `resp.up` side and round-trips independently of the downstream body. The // `meta.targetApi` names the target protocol so the dashboard can render the @@ -199,7 +290,7 @@ test('FileDumpStore round-trips the upstream stream body alongside the downstrea const db = await openDb(); const files = new MemoryFileStore(); const store = new FileDumpStore(db, files); - const record: DumpWriteRecord = { + const record: DumpWriteEdgeRecord = { ...baseRecord('01HZZ0000000000000000000UP1', Date.UTC(2026, 5, 1, 12, 0, 0)), meta: { ...baseRecord('01HZZ0000000000000000000UP1', Date.UTC(2026, 5, 1, 12, 0, 0)).meta, @@ -226,7 +317,7 @@ test('FileDumpStore round-trips the upstream stream body alongside the downstrea }, }; await store.put('key_x', record); - const fetched = await store.get('key_x', '01HZZ0000000000000000000UP1'); + const fetched = edgeOf(await store.get('key_x', '01HZZ0000000000000000000UP1')); assertExists(fetched); assertEquals(fetched.meta.targetApi, 'openaiResponses'); const upstream = fetched.response.upstream; @@ -240,7 +331,7 @@ test('FileDumpStore round-trips the upstream bytes body (api-error envelope)', a const db = await openDb(); const files = new MemoryFileStore(); const store = new FileDumpStore(db, files); - const record: DumpWriteRecord = { + const record: DumpWriteEdgeRecord = { ...baseRecord('01HZZ0000000000000000000UP2', Date.UTC(2026, 5, 1, 12, 0, 0)), meta: { ...baseRecord('01HZZ0000000000000000000UP2', Date.UTC(2026, 5, 1, 12, 0, 0)).meta, @@ -258,7 +349,7 @@ test('FileDumpStore round-trips the upstream bytes body (api-error envelope)', a }, }; await store.put('key_x', record); - const fetched = await store.get('key_x', '01HZZ0000000000000000000UP2'); + const fetched = edgeOf(await store.get('key_x', '01HZZ0000000000000000000UP2')); assertExists(fetched); const upstream = fetched.response.upstream; assertExists(upstream); @@ -275,7 +366,7 @@ test('FileDumpStore omits upstream and targetApi for a native (non-translated) r const store = new FileDumpStore(db, files); const record = baseRecord('01HZZ0000000000000000000NT1', Date.UTC(2026, 5, 1, 12, 0, 0)); await store.put('key_x', record); - const fetched = await store.get('key_x', '01HZZ0000000000000000000NT1'); + const fetched = edgeOf(await store.get('key_x', '01HZZ0000000000000000000NT1')); assertExists(fetched); assertEquals(fetched.response.upstream, undefined); assertEquals(fetched.meta.targetApi, undefined); @@ -324,7 +415,7 @@ test('FileDumpStore rejects malformed stream events with record and file context const db = await openDb(); const files = new MemoryFileStore(); const store = new FileDumpStore(db, files); - const record: DumpWriteRecord = { + const record: DumpWriteEdgeRecord = { ...baseRecord('01HZZ000000000000000000BADE', Date.UTC(2026, 5, 1, 12)), response: { status: 200, @@ -527,8 +618,7 @@ test('FileDumpStore: put + get round-trips through real-filesystem IO', async () const record = baseRecord('01HZZ0000000000000000000A1', Date.UTC(2026, 5, 1, 12, 0, 0)); await store.put('key_x', record); - const fetched = await store.get('key_x', '01HZZ0000000000000000000A1'); - assertExists(fetched); + const fetched = edgeOf(await store.get('key_x', '01HZZ0000000000000000000A1')); assertEquals(new TextDecoder().decode(fetched.request.body), '{"hello":"world"}'); if (fetched.response.body.type !== 'bytes') throw new Error('expected bytes'); assertEquals(new TextDecoder().decode(fetched.response.body.body), '{"id":"abc"}'); @@ -549,8 +639,8 @@ test('FileDumpStore round-trips raw exchanges and parsed events in one owned spi record.response = { ...record.response, upstream: { status: 200, headers: [['x-trace', 'upstream']], body: { type: 'stream', events: [{ ts: 0, frame: { type: 'done' } }] } } }; await store.put('key_x', record); const stored = await store.get('key_x', record.meta.id); - expect(stored?.capture).toEqual(record.capture); - expect(stored?.response.upstream).toEqual(record.response.upstream); + expect(edgeOf(stored).capture).toEqual(record.capture); + expect(edgeOf(stored!).response.upstream).toEqual(record.response.upstream); const spill = await db.prepare("SELECT file_key, state FROM spilled_files WHERE owner_kind = 'dump-response-upstream'").first<{ file_key: string; state: string }>(); expect(spill?.state).toBe('owned'); await db.prepare('DELETE FROM dump_records WHERE id = ?').bind(record.meta.id).run(); diff --git a/packages/gateway/__tests__/scheduled/expiration-sweeps_test.ts b/packages/gateway/__tests__/scheduled/expiration-sweeps_test.ts index 0d396096fe..58a781599d 100644 --- a/packages/gateway/__tests__/scheduled/expiration-sweeps_test.ts +++ b/packages/gateway/__tests__/scheduled/expiration-sweeps_test.ts @@ -39,6 +39,7 @@ const responseItem = (id: string, refreshedAt: number, apiKeyId = 'key-a'): Stor }); const dumpRecord = (id: string, completedAt: number): DumpWriteRecord => ({ + shape: 'edge', meta: { id, startedAt: completedAt - 1, diff --git a/packages/gateway/__tests__/scheduled_test.ts b/packages/gateway/__tests__/scheduled_test.ts index cde31d7010..742218ad92 100644 --- a/packages/gateway/__tests__/scheduled_test.ts +++ b/packages/gateway/__tests__/scheduled_test.ts @@ -31,6 +31,7 @@ const apiKey = (id: string, now: number, secretDigit: number): ApiKey => ({ }); const fileBackedDumpRecord = (id: string, completedAt: number): DumpWriteRecord => ({ + shape: 'edge', meta: { id, startedAt: completedAt - 1, diff --git a/packages/gateway/__tests__/test-utils/provider-pipeline.ts b/packages/gateway/__tests__/test-utils/provider-pipeline.ts new file mode 100644 index 0000000000..ea11129195 --- /dev/null +++ b/packages/gateway/__tests__/test-utils/provider-pipeline.ts @@ -0,0 +1,35 @@ +import type { GatewayCtx } from '../../src/data-plane/shared/gateway-ctx.ts'; +import { takeHttpResponse } from '@floway-dev/http/pipeline'; +import { compose, defineStage, isSecret, move, type Pipeline } from '@floway-dev/pipeline'; +import { directFetcher, identityWrapUpstreamCall, type ProviderCallResult, type ProviderModelFacts, type ProviderOperation, type ProviderOperationPayloads, type ProviderResponse, type ProviderRerankResponse, type ProviderRequest, type UpstreamCallOptions } from '@floway-dev/provider'; + +export const stubProviderPipeline = ( + operation: O, + call: (model: ProviderModelFacts, payload: ProviderOperationPayloads[O], signal: AbortSignal | undefined, options: UpstreamCallOptions) => Promise, +): Pipeline, O extends 'rerank' ? ProviderRerankResponse : ProviderResponse> => compose, ProviderResponse>(`stub.${operation}`, [ + defineStage, ProviderResponse, { gateway: GatewayCtx }>({ + name: 'stubHttp', + return: { provides: ['response.http.exchange', 'response.http.body', 'response.provider.modelKey', 'response.provider.called', 'response.provider.previousCalls'] }, + execute: async (facts, use) => { + let result: ProviderCallResult; + // The fixture callback stands for the transport, so its rejection is a transport outcome. + try { + result = await call(facts['request.provider.model'], facts['request.provider.payload'], use.gateway.abortSignal, { + fetcher: directFetcher, waitUntil: use.gateway.backgroundScheduler, wrapUpstreamCall: identityWrapUpstreamCall, + headers: new Headers(facts['request.http.headers'].map(([name, value]): [string, string] => [name, isSecret(value) ? value.reveal() : value])), + }); + } catch (error) { + return move({ + ...facts, 'response.http.exchange': { type: 'transportFailure', error }, 'response.http.body': null, + 'response.provider.modelKey': facts['request.provider.model'].id, 'response.provider.called': false, 'response.provider.previousCalls': [], + }); + } + const exchange = takeHttpResponse(result.response); + return move({ + ...facts, 'response.http.exchange': exchange, 'response.http.body': exchange.body, + 'response.provider.modelKey': result.modelKey, 'response.provider.called': true, 'response.provider.previousCalls': [], + ...('target' in result ? { 'response.provider.rerankTarget': result.target } : {}), + }); + }, + }), +]) as Pipeline, O extends 'rerank' ? ProviderRerankResponse : ProviderResponse>; diff --git a/packages/gateway/__tests__/vitest.setup.ts b/packages/gateway/__tests__/vitest.setup.ts index 5ca7278aff..0f06a9ce47 100644 --- a/packages/gateway/__tests__/vitest.setup.ts +++ b/packages/gateway/__tests__/vitest.setup.ts @@ -1,11 +1,12 @@ import { trackBackground } from './test-utils/background-tracker.ts'; import { initDumpBroker, initDumpStore } from '../src/dump/registry.ts'; import type { DumpStore } from '../src/dump/store-contract.ts'; -import type { DumpMetadata, StoredDumpRecord, DumpRecordId } from '../src/dump/types.ts'; +import type { StoredDumpRecord } from '../src/dump/types.ts'; import { handleExecutionRequest } from '../src/execution/handler.ts'; import { initBackgroundSchedulerResolver } from '../src/runtime/background.ts'; import { initExecutionCellNamespace } from '../src/runtime/execution.ts'; import type { DumpBroker } from '@floway-dev/dump'; +import type { DumpMetadata, DumpRecordId } from '@floway-dev/dump/types'; import { isReplayableBody } from '@floway-dev/http'; import { initEnv, initFetch, initRuntimeKind, initTimingSafeEqual, InProcessExecutionCellNamespace } from '@floway-dev/platform'; diff --git a/packages/gateway/package.json b/packages/gateway/package.json index 97dd4f957f..21399d5843 100644 --- a/packages/gateway/package.json +++ b/packages/gateway/package.json @@ -39,6 +39,7 @@ "@floway-dev/dump": "workspace:*", "@floway-dev/http": "workspace:*", "@floway-dev/interceptor": "workspace:*", + "@floway-dev/pipeline": "workspace:*", "@floway-dev/platform": "workspace:*", "@floway-dev/protocols": "workspace:*", "@floway-dev/provider": "workspace:*", diff --git a/packages/gateway/src/data-plane/alpha-search/call-upstream.ts b/packages/gateway/src/data-plane/alpha-search/call-upstream.ts new file mode 100644 index 0000000000..fbe6456737 --- /dev/null +++ b/packages/gateway/src/data-plane/alpha-search/call-upstream.ts @@ -0,0 +1,117 @@ +import type { Fields } from './facts.ts'; +import { parseAlphaSearchResponse } from './protocol.ts'; +import { providerEntry } from '../pipeline/provider-entry.ts'; +import { providerUsage } from '../pipeline/provider-usage.ts'; +import type { GatewayServices } from '../pipeline/services.ts'; +import { dialFailure, spentBody } from '../pipeline/upstream-body.ts'; +import { telemetryModelIdentity } from '../shared/telemetry/attribution.ts'; +import { exchangeResponse } from '@floway-dev/http/pipeline'; +import { defineStage, move } from '@floway-dev/pipeline'; +import { type ProviderRequest, type ProviderResponse, type ProviderOperationPayloads } from '@floway-dev/provider'; + +/** Codex projects one per-turn metadata snapshot onto several surfaces, and `SearchRequest` + * carries no field for it — so on this endpoint the header is the whole of that surface, and + * it is the only inbound header this call reads. + * https://github.com/openai/codex/blob/2e1607ee2fa8099a233df7437adee5f16a741905/codex-rs/codex-api/src/search.rs#L8-L29 */ +const turnMetadataHeaders = (ingress: readonly (readonly [string, string])[]): Headers => { + const headers = new Headers(); + const metadata = ingress.find(([name]) => name.toLowerCase() === 'x-codex-turn-metadata'); + if (metadata !== undefined) headers.set('x-codex-turn-metadata', metadata[1]); + return headers; +}; + +/** The body as JSON, or nothing when it was not JSON at all. Which of the two happened is + * what the caller reports; here it is only the question being asked. */ +const asJson = (raw: string): unknown => { + try { + return JSON.parse(raw) as unknown; + } catch { + return undefined; + } +}; + +/** + * The pinned ending. Dials the operator's search upstream and provides what came back plus + * what the call is billable for. A failure is a value here as everywhere, even though this + * family has nothing that fails over it: the edge is what turns one into a status and a body. + */ +export const callSearchUpstream = defineStage< + Fields<'request.search.alphaSearch' | 'ingress.http.headers' | 'route.attempt'>, + ProviderRequest, + ProviderResponse, + Fields<'response.search.alphaSearch' | 'response.usage.billable' | 'response.http.headers' | 'response.http.status' | 'response.http.body'>, + GatewayServices +>({ + name: 'callSearchUpstream', + into: { + request: { needs: ['request.search.alphaSearch', 'ingress.http.headers', 'route.attempt'], consumes: [], provides: ['request.provider.model', 'request.provider.payload', 'request.http.callId', 'request.http.headers'] }, + response: { needs: ['response.http.exchange', 'response.provider.modelKey', 'response.provider.called', 'response.provider.previousCalls', 'response.http.body'], consumes: ['response.http.exchange', 'response.provider.modelKey', 'response.provider.called', 'response.provider.previousCalls'], provides: ['response.search.alphaSearch', 'response.usage.billable', 'response.http.headers', 'response.http.status', 'response.http.body'] }, + }, + execute: async (facts, next, use) => { + const candidate = use.resolveAttempt(facts['route.attempt']); + // The caller's model is dropped: this endpoint is pinned to the operator's model, and the + // provider stamps that one on the way out. + // TODO: pin SearchRequest.id to one provider account when Codex upstreams support account + // pools. The current Codex provider has one active account. + const { model: _named, ...request } = facts['request.search.alphaSearch']; + const pipeline = candidate.provider.pipelines.alphaSearch; + if (pipeline === undefined) throw new Error(`Provider ${candidate.provider.kind} has no Alpha Search pipeline`); + const back = await next(providerEntry(facts, candidate, request, [...turnMetadataHeaders(facts['ingress.http.headers'])]), pipeline); + const { 'response.http.exchange': exchange, 'response.provider.modelKey': modelKey, 'response.provider.called': wasCalled, 'response.provider.previousCalls': _previousCalls, ...rest } = back; + if (exchange.type === 'transportFailure') { + return move({ ...rest, 'response.search.alphaSearch': dialFailure(exchange.error), 'response.usage.billable': providerUsage(candidate, back, []), 'response.http.headers': [], 'response.http.status': 502 }); + } + const result = { response: exchangeResponse(exchange), modelKey }; + // The alpha-search protocol reports no usage at all, so the entity is present with no + // quantities — the upstream was called and reported nothing, which is a different + // situation from reporting zero. + const billable = wasCalled ? [{ identity: telemetryModelIdentity(candidate, result.modelKey), quantities: {} }] : []; + + // Every protocol the gateway carries is one it fully understands: the body is read here + // and serialized again at the edge, an error body included. + spentBody(exchange.body); + const raw = await result.response.text(); + if (!result.response.ok) { + await use.log.warn('upstream refused', { status: result.response.status }); + // The message is what came back as text and the body is the same thing parsed: a dump + // reader gets the upstream's own words either way, and only the parsed form is + // something the edge can serialize back out. + const body = asJson(raw); + return move({ + ...rest, + 'response.search.alphaSearch': { + status: result.response.status, + message: raw, + ...(body === undefined ? {} : { body }), + }, + 'response.usage.billable': providerUsage(candidate, back, billable), + 'response.http.headers': [...result.response.headers], + 'response.http.status': result.response.status, + }); + } + + const verdict = parseAlphaSearchResponse(asJson(raw)); + if (!verdict.ok) { + // A protocol that requires JSON and receives something else synthesizes its own error, + // which is also why the raw text rides along: a dump reader is owed what came back. + return move({ + ...rest, + 'response.search.alphaSearch': { + status: 502, + message: `The search upstream answered ${result.response.status} but not in the search protocol: ${verdict.reason}.`, + body: raw, + }, + 'response.usage.billable': providerUsage(candidate, back, billable), + 'response.http.headers': [...result.response.headers], + 'response.http.status': result.response.status, + }); + } + return move({ + ...rest, + 'response.search.alphaSearch': verdict.response, + 'response.usage.billable': providerUsage(candidate, back, billable), + 'response.http.headers': [...result.response.headers], + 'response.http.status': result.response.status, + }); + }, +}); diff --git a/packages/gateway/src/data-plane/alpha-search/emit.ts b/packages/gateway/src/data-plane/alpha-search/emit.ts new file mode 100644 index 0000000000..316cdfc1c0 --- /dev/null +++ b/packages/gateway/src/data-plane/alpha-search/emit.ts @@ -0,0 +1,59 @@ +import type { Fields } from './facts.ts'; +import { renderAlphaSearchResponse } from './protocol.ts'; +import { isJsonObject } from '../../shared/json-helpers.ts'; +import { isFailure } from '../pipeline/facts.ts'; +import { isForwardableUpstreamHeader } from '../shared/upstream-response.ts'; +import { defineStage, move } from '@floway-dev/pipeline'; + +/** + * The outermost edge. Renders the answer into Codex's protocol and says what status the + * client is owed — the one thing a failure value carries that a rendered body cannot. + * + * It declares needing `response.usage.billable` without reading it, which is this family's + * statement that every path accounts for usage: assembly then rejects an ending, or a + * short-circuit, that does not provide it. + */ +export const emitAlphaSearch = defineStage< + Fields, + Fields, + Fields<'response.search.alphaSearch' | 'response.usage.billable' | 'response.http.headers' | 'response.http.status'>, + Fields<'response.search.rendered' | 'response.http.status' | 'response.http.headers'> +>({ + name: 'emitAlphaSearch', + through: { + request: { needs: [], consumes: [], provides: [] }, + response: { + needs: ['response.search.alphaSearch', 'response.usage.billable', 'response.http.headers', 'response.http.status'], + consumes: ['response.search.alphaSearch', 'response.http.headers'], + provides: ['response.search.rendered', 'response.http.status', 'response.http.headers'], + }, + }, + execute: async (facts, next) => { + const back = await next(facts); + const { 'response.search.alphaSearch': answer, 'response.http.headers': headers, ...rest } = back; + // Vendor traces and quota state stay visible; what an intermediary must strip, and what + // would misdescribe a body this gateway serialized itself, does not. A filter that removed + // nothing hands the same array on, so the record shows no change where none happened. + const forwardable = headers.filter(([name]) => isForwardableUpstreamHeader(name)); + const forClient = forwardable.length === headers.length ? headers : move(forwardable); + if (isFailure(answer)) { + // An upstream error body is JSON like any other: it was parsed below and is serialized + // again here. A body that was not an object is one this protocol cannot carry, so what + // goes out instead is the gateway's own envelope. + return { + ...rest, + 'response.http.headers': forClient, + 'response.search.rendered': move(isJsonObject(answer.body) + ? answer.body + : { error: { message: answer.message, type: 'api_error' } }), + 'response.http.status': answer.status, + }; + } + return { + ...rest, + 'response.http.headers': forClient, + 'response.search.rendered': move(renderAlphaSearchResponse(answer)), + 'response.http.status': back['response.http.status'], + }; + }, +}); diff --git a/packages/gateway/src/data-plane/alpha-search/execute-operations.ts b/packages/gateway/src/data-plane/alpha-search/execute-operations.ts new file mode 100644 index 0000000000..9702ec51f8 --- /dev/null +++ b/packages/gateway/src/data-plane/alpha-search/execute-operations.ts @@ -0,0 +1,50 @@ +import type { Fields } from './facts.ts'; +import type { SearchServices } from './services.ts'; +import { startBatchFetch, executeOperationToText, type WebSearchExecutionSession } from '../tools/web-search/operations.ts'; +import { defineStage, move } from '@floway-dev/pipeline'; + +/** + * The local ending. Runs every operation against the configured backend and provides the + * answer plus what the run is billable for — which is nothing, because no upstream model was + * called. What the search backend itself charges is accounted per api key by the operations + * as they run, in units no model prices. + */ +export const executeSearchOperations = defineStage< + Fields<'request.search.operations' | 'request.search.filters' | 'request.search.responseLength'>, + Fields<'response.search.alphaSearch' | 'response.usage.billable' | 'response.http.headers' | 'response.http.status'>, + SearchServices +>({ + name: 'executeSearchOperations', + return: { provides: ['response.search.alphaSearch', 'response.usage.billable', 'response.http.headers', 'response.http.status'] }, + execute: async (facts, use) => { + const responseLength = facts['request.search.responseLength']; + const session: WebSearchExecutionSession = { + ...(responseLength === null ? {} : { responseLength }), + getProvider: use.searchProvider, + filters: facts['request.search.filters'], + apiKeyId: use.gateway.apiKeyId, + pageCache: new Map(), + // Codex renders `output` as plain text; the search-action sources list is a Responses + // protocol concern with no place here. + includeSearchActionSources: false, + ...(use.gateway.abortSignal === undefined ? {} : { signal: use.gateway.abortSignal }), + }; + + // One batched fetchPage covers every open and find URL; each operation then renders its + // own text block, in the parser's canonical order — search_query, open, find, preserving + // array order within each command kind. + const ops = [...facts['request.search.operations']]; + const batch = await startBatchFetch({ kind: 'ops', ops }, session); + const blocks = await Promise.all(ops.map(op => executeOperationToText(op, session, batch))); + await use.log.debug('ran the search operations', { operations: ops.length }); + + return move({ + ...facts, + 'response.search.alphaSearch': { encryptedOutput: null, output: blocks.join('\n\n') }, + 'response.usage.billable': [], + // Nothing was called, so there are no upstream headers to carry. + 'response.http.headers': [], + 'response.http.status': 200, + }); + }, +}); diff --git a/packages/gateway/src/data-plane/alpha-search/facts.ts b/packages/gateway/src/data-plane/alpha-search/facts.ts new file mode 100644 index 0000000000..8c07858d9d --- /dev/null +++ b/packages/gateway/src/data-plane/alpha-search/facts.ts @@ -0,0 +1,35 @@ +import type { AlphaSearchRequest, AlphaSearchResponse } from './protocol.ts'; +import type { GatewayFacts, Failure } from '../pipeline/facts.ts'; +import type { SearchResponseLength, WebSearchOperation, WebSearchFilters } from '../tools/web-search/operations.ts'; + +/** Search's own keys. They extend the shared space and never merge into it, so a stage + * written against the gateway alone cannot name one. */ +export interface SearchFacts extends GatewayFacts { + 'request.search.alphaSearch': AlphaSearchRequest; + /** What Codex's commands mean to this gateway. Local execution runs on these and never + * reads Codex's request again, which is why the stage that provides them consumes it. */ + 'request.search.operations': readonly WebSearchOperation[]; + 'request.search.filters': WebSearchFilters; + /** Codex's `commands.response_length`, which sizes every search the operations run; null + * when the client left the backend's default in place. */ + 'request.search.responseLength': SearchResponseLength | null; + 'response.search.alphaSearch': AlphaSearchResponse | Failure; + /** What the client is actually sent, in Codex's protocol. The edge provides it, so a dump + * shows the bytes the client received rather than the gateway's own form. */ + 'response.search.rendered': Record; +} + +export type Fields = { [P in K]: SearchFacts[P] }; + +/** The operator's pinned search upstream. One upstream and one model, both from + * configuration, which is why nothing here narrows a candidate list. */ +export interface PinnedSearchUpstream { + readonly kind: 'upstream'; + readonly upstreamId: string; + readonly model: string; +} + +/** Which ending the chain gets. `upstream` is the operator's `passthroughOpenAiSearch` + * setting: the word there is about whose search results the client is given, not about + * carrying a protocol the gateway has not parsed. */ +export type SearchExecution = { readonly kind: 'local' } | PinnedSearchUpstream; diff --git a/packages/gateway/src/data-plane/alpha-search/http.ts b/packages/gateway/src/data-plane/alpha-search/http.ts new file mode 100644 index 0000000000..fe38d1c61a --- /dev/null +++ b/packages/gateway/src/data-plane/alpha-search/http.ts @@ -0,0 +1,97 @@ +// POST /alpha/search, served through the pipeline. +// +// What the handler decides before the run is which ending the chain gets: the operator's +// `passthroughOpenAiSearch` setting pins an upstream, and everything else runs the commands +// here. That is a property of configuration rather than of the request, so it is read once and +// the chain is assembled around it — which is why the family has one pipeline builder rather +// than a stage that branches. +// +// The configured search backend reaches the chain as a service and never as a fact: it holds +// live handles built from the operator's provider credential, so none of it is dumpable and +// none of it belongs in the record. + +import type { Context } from 'hono'; + +import { type SearchExecution } from './facts.ts'; +import { searchServePipeline } from './pipeline.ts'; +import { alphaSearchRequestSchema, type AlphaSearchRequest } from './protocol.ts'; +import { type SearchServices } from './services.ts'; +import type { AuthedContext } from '../../middleware/auth.ts'; +import { openPrologue, readIngress, serveThrough, type Prologue } from '../pipeline/serve.ts'; +import { finalizeGatewayResponse } from '../shared/gateway-ctx.ts'; +import { loadWebSearchConfig } from '../tools/web-search/config.ts'; +import { resolveConfiguredWebSearchProvider } from '../tools/web-search/provider.ts'; +import type { ConfiguredWebSearchProvider, WebSearchConfig } from '../tools/web-search/types.ts'; +import { move } from '@floway-dev/pipeline'; + +/** Which ending this gateway's configuration asks for. The word "passthrough" is about whose + * search results the client is given, not about carrying a protocol the gateway has not + * parsed — what comes back is read and written again either way. */ +const executionFor = (config: WebSearchConfig): SearchExecution => + config.passthroughOpenAiSearch.enabled + ? { kind: 'upstream', upstreamId: config.passthroughOpenAiSearch.upstreamId, model: config.passthroughOpenAiSearch.model } + : { kind: 'local' }; + +/** Resolved once per turn and shared by every operation in it: one turn's commands run against + * one backend, and resolving per operation would build the same handles again. */ +const searchProviderFor = (config: WebSearchConfig): SearchServices['searchProvider'] => { + let resolved: Promise | undefined; + return () => { + resolved ??= Promise.resolve(resolveConfiguredWebSearchProvider(config)); + return resolved; + }; +}; + +/** The request as this protocol, or the sentence saying why it is not. The family reads its own + * body because the run is given the bytes the client sent, and a validator middleware would + * have consumed them first. */ +type Read = { readonly ok: true; readonly request: AlphaSearchRequest } | { readonly ok: false; readonly message: string }; + +const readAlphaSearchRequest = (bytes: Uint8Array): Read => { + let body: unknown; + try { + body = JSON.parse(new TextDecoder().decode(bytes)) as unknown; + } catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) }; + } + const parsed = alphaSearchRequestSchema.safeParse(body); + // The first issue's message, verbatim: a schema attaches a field-aware message where it wants + // one, and prepending the path would name the field twice. + return parsed.success + ? { ok: true, request: parsed.data } + : { ok: false, message: parsed.error.issues[0]?.message ?? 'Invalid input' }; +}; + +export const alphaSearch = async (c: Context): Promise => { + const ingress = await readIngress(c); + const read = readAlphaSearchRequest(ingress.body.bytes); + if (!read.ok) { + // A request the gateway could not read never reaches a pipeline: there is no backend to + // reach and no attempt to make, so there is nothing for a run to record. + const refused = openPrologue(c as AuthedContext, ingress, { wantsStream: false }); + refused.gateway.dump?.error('gateway'); + return finalizeGatewayResponse(refused.gateway, Response.json({ error: read.message }, { status: 400 })); + } + + const config = await loadWebSearchConfig(); + const execution = executionFor(config); + // A pinned turn is attributed to the model the operator pinned, because that is the one that + // will be called: the id the caller sent names a model of its own that never travels. + const prologue = openPrologue(c as AuthedContext, ingress, { + wantsStream: false, + ...(execution.kind === 'upstream' ? { model: execution.model } : {}), + }); + const services: SearchServices = { ...prologue.services, searchProvider: searchProviderFor(config) }; + const withBackend: Prologue = { ...prologue, services }; + + return await serveThrough( + c, + withBackend, + searchServePipeline(execution), + move({ + 'ingress.http.headers': prologue.headers, + 'request.search.alphaSearch': read.request, + }) as never, + facts => ({ body: facts['response.http.jsonBody']!, contentType: 'application/json' }), + ); +}; diff --git a/packages/gateway/src/data-plane/alpha-search/parse-operations.ts b/packages/gateway/src/data-plane/alpha-search/parse-operations.ts new file mode 100644 index 0000000000..333216af6f --- /dev/null +++ b/packages/gateway/src/data-plane/alpha-search/parse-operations.ts @@ -0,0 +1,62 @@ +import type { Fields } from './facts.ts'; +import { webSearchFiltersFromSettings } from './protocol.ts'; +import { assertLocalWebSearchSupport, isSearchResponseLength, UnsupportedLocalWebSearchFeatureError, parseWebSearchOperations } from '../tools/web-search/operations.ts'; +import { move, defineStage } from '@floway-dev/pipeline'; + +/** Both in-band answers a local run can give: Codex reads `output`, so a command it asked for + * and this gateway cannot run is text the model sees rather than an HTTP failure. */ +const inBandOutput = (facts: Fields<'request.search.alphaSearch'>, output: string) => move({ + ...facts, + 'response.search.alphaSearch': { encryptedOutput: null, output }, + 'response.usage.billable': [], + // Nothing was called, so there are no upstream headers to carry. + 'response.http.headers': [], + 'response.http.status': 200, +}); + +/** + * Codex's commands become the gateway's own operations, and its settings become the filters + * they run under. Nothing below reads Codex's request afterwards, so it is consumed here and + * assembly is what holds that: a stage placed under this one cannot need it back. + */ +export const parseSearchOperations = defineStage< + Fields<'request.search.alphaSearch'>, + Fields<'request.search.operations' | 'request.search.filters' | 'request.search.responseLength'>, + Fields<'response.search.alphaSearch' | 'response.usage.billable' | 'response.http.headers' | 'response.http.status'>, + Fields<'response.search.alphaSearch' | 'response.usage.billable' | 'response.http.headers' | 'response.http.status'>, + Fields<'response.search.alphaSearch' | 'response.usage.billable' | 'response.http.headers' | 'response.http.status'> +>({ + name: 'parseSearchOperations', + through: { + request: { + needs: ['request.search.alphaSearch'], + consumes: ['request.search.alphaSearch'], + provides: ['request.search.operations', 'request.search.filters', 'request.search.responseLength'], + }, + response: { needs: [], consumes: [], provides: [] }, + }, + return: { provides: ['response.search.alphaSearch', 'response.usage.billable', 'response.http.headers', 'response.http.status'] }, + execute: async (facts, next) => { + const { 'request.search.alphaSearch': request, ...rest } = facts; + const commands = request.commands ?? {}; + + try { + assertLocalWebSearchSupport(commands); + } catch (error) { + if (!(error instanceof UnsupportedLocalWebSearchFeatureError)) throw error; + return inBandOutput(facts, error.message); + } + + const parsed = parseWebSearchOperations(commands); + if (parsed.kind !== 'ops' || parsed.ops.length === 0) { + return inBandOutput(facts, 'No web search commands were provided. Populate at least one of `search_query`, `open`, or `find`.'); + } + + return await next({ + ...rest, + 'request.search.operations': move(parsed.ops), + 'request.search.filters': move(webSearchFiltersFromSettings(request.settings)), + 'request.search.responseLength': isSearchResponseLength(commands.response_length) ? commands.response_length : null, + }); + }, +}); diff --git a/packages/gateway/src/data-plane/alpha-search/pipeline.ts b/packages/gateway/src/data-plane/alpha-search/pipeline.ts new file mode 100644 index 0000000000..326eb76218 --- /dev/null +++ b/packages/gateway/src/data-plane/alpha-search/pipeline.ts @@ -0,0 +1,23 @@ +import { callSearchUpstream } from './call-upstream.ts'; +import { emitAlphaSearch } from './emit.ts'; +import { executeSearchOperations } from './execute-operations.ts'; +import type { SearchExecution, Fields } from './facts.ts'; +import { parseSearchOperations } from './parse-operations.ts'; +import { resolveSearchUpstream } from './resolve-upstream.ts'; +import { serializeClientJson } from '../pipeline/serialize-client-json.ts'; +import { writeSettlement } from '../pipeline/settlement.ts'; +import { compose, type Pipeline } from '@floway-dev/pipeline'; + +export const searchServePipeline = (execution: SearchExecution): Pipeline< + Fields<'request.search.alphaSearch'>, + Fields<'response.http.jsonBody' | 'response.search.rendered' | 'response.http.status' | 'response.http.headers' | 'response.usage.billable'> +> => compose('searchServe', [ + // Unconditional, as everywhere: a run that searched locally reached no upstream and bills + // for none, and saying so is a row that names no billed entity rather than no row. + writeSettlement(handedUp => Number(handedUp['response.http.status']) >= 400), + serializeClientJson('response.search.rendered'), + emitAlphaSearch, + ...(execution.kind === 'local' + ? [parseSearchOperations, executeSearchOperations] + : [resolveSearchUpstream(execution), callSearchUpstream]), +]); diff --git a/packages/gateway/src/data-plane/alpha-search/protocol.ts b/packages/gateway/src/data-plane/alpha-search/protocol.ts new file mode 100644 index 0000000000..f2682bdf6f --- /dev/null +++ b/packages/gateway/src/data-plane/alpha-search/protocol.ts @@ -0,0 +1,115 @@ +// Codex's `/alpha/search` protocol. The request carries model/session context plus a +// command object; the response is `{ encrypted_output, output, results? }`. +// https://github.com/openai/codex/blob/2e1607ee2fa8099a233df7437adee5f16a741905/codex-rs/codex-api/src/search.rs#L8-L29 +// https://github.com/openai/codex/blob/2e1607ee2fa8099a233df7437adee5f16a741905/codex-rs/codex-api/src/search.rs#L297-L305 + +import { z } from 'zod'; + +import { isJsonObject } from '../../shared/json-helpers.ts'; +import { maxResultsForContextSize, type WebSearchFilters } from '../tools/web-search/operations.ts'; + +const domainListSchema = z.array(z.string()); + +// This is OpenAI Codex's complete SearchSettings shape. The loose object keeps future fields +// intact; local execution consumes the routing fields it implements while accepting Codex +// metadata such as allowed_callers. +// https://github.com/openai/codex/blob/2f19a57704fb7b1db032bc38cf995034254eaebb/codex-rs/codex-api/src/search.rs#L215-L295 +const searchSettingsSchema = z.looseObject({ + filters: z.looseObject({ + allowed_domains: domainListSchema.optional(), + blocked_domains: domainListSchema.optional(), + }).optional(), + user_location: z.looseObject({ + type: z.literal('approximate').optional(), + city: z.string().optional(), + region: z.string().optional(), + country: z.string().optional(), + timezone: z.string().optional(), + }).optional(), + search_context_size: z.enum(['low', 'medium', 'high']).optional(), + image_settings: z.looseObject({ + max_results: z.number().int().nonnegative().optional(), + caption: z.boolean().optional(), + }).optional(), + allowed_callers: z.array(z.enum(['direct', 'shell', 'code_interpreter'])).optional(), + external_web_access: z.union([ + z.boolean(), + z.enum(['cached', 'indexed', 'live']), + ]).optional(), +}); + +// `commands` is validated only as "an object" — the per-kind arrays are parsed by the shared +// command engine. `looseObject` preserves every OpenAI command and nested parameter so a +// relayed request stays lossless and the local capability gate can reject unimplemented +// fields explicitly. +// https://github.com/openai/codex/blob/2f19a57704fb7b1db032bc38cf995034254eaebb/codex-rs/codex-api/src/search.rs#L31-L213 +export const alphaSearchRequestSchema = z.looseObject({ + commands: z.looseObject({}).optional(), + settings: searchSettingsSchema.optional(), +}); + +export type AlphaSearchRequest = z.infer; + +export interface AlphaSearchResponse { + /** Search state Codex carries forward into a later turn, opaque to everyone between the + * two ends. Local execution has no state to carry, so it is null. */ + encryptedOutput: string | null; + /** The model-facing text. Codex renders this and nothing else. */ + output: string; + /** Result DTOs handed to clients out of band from `output`. Codex keeps them opaque so + * newer result variants stay forward-compatible, and so does this. */ + results?: readonly unknown[]; +} + +/** Why a body could not be read as this protocol, so the ending stage can say so in the + * synthesized failure rather than reporting a bare status. */ +export type AlphaSearchResponseVerdict = + | { readonly ok: true; readonly response: AlphaSearchResponse } + | { readonly ok: false; readonly reason: string }; + +export const parseAlphaSearchResponse = (body: unknown): AlphaSearchResponseVerdict => { + if (!isJsonObject(body)) return { ok: false, reason: 'the body is not a JSON object' }; + const { encrypted_output: encryptedOutput, output, results } = body; + if (typeof output !== 'string') return { ok: false, reason: '`output` is missing or not a string' }; + // `encrypted_output` is `Option` without a serde default, so Codex's own client + // requires the key and accepts null in it. Demanding the same here surfaces a truncated + // upstream at the gateway instead of as a deserialization failure inside the client. + if (encryptedOutput !== null && typeof encryptedOutput !== 'string') { + return { ok: false, reason: '`encrypted_output` is missing or is neither a string nor null' }; + } + if (results !== undefined && results !== null && !Array.isArray(results)) { + return { ok: false, reason: '`results` is neither absent nor an array' }; + } + return { + ok: true, + response: { + encryptedOutput, + output, + ...(Array.isArray(results) ? { results } : {}), + }, + }; +}; + +export const renderAlphaSearchResponse = (response: AlphaSearchResponse): Record => ({ + encrypted_output: response.encryptedOutput, + output: response.output, + ...(response.results === undefined ? {} : { results: response.results }), +}); + +export const webSearchFiltersFromSettings = (settings: AlphaSearchRequest['settings']): WebSearchFilters => { + const filters: WebSearchFilters = { + maxResults: maxResultsForContextSize(settings?.search_context_size), + }; + if (settings?.filters?.allowed_domains) filters.allowedDomains = settings.filters.allowed_domains; + if (settings?.filters?.blocked_domains) filters.blockedDomains = settings.filters.blocked_domains; + const loc = settings?.user_location; + if (loc && (loc.city !== undefined || loc.region !== undefined || loc.country !== undefined || loc.timezone !== undefined)) { + filters.userLocation = { + ...(loc.city !== undefined ? { city: loc.city } : {}), + ...(loc.region !== undefined ? { region: loc.region } : {}), + ...(loc.country !== undefined ? { country: loc.country } : {}), + ...(loc.timezone !== undefined ? { timezone: loc.timezone } : {}), + }; + } + return filters; +}; diff --git a/packages/gateway/src/data-plane/alpha-search/resolve-upstream.ts b/packages/gateway/src/data-plane/alpha-search/resolve-upstream.ts new file mode 100644 index 0000000000..f91a2def52 --- /dev/null +++ b/packages/gateway/src/data-plane/alpha-search/resolve-upstream.ts @@ -0,0 +1,50 @@ +import type { PinnedSearchUpstream, Fields } from './facts.ts'; +import type { GatewayServices } from '../pipeline/services.ts'; +import { AlphaSearchModelNotFoundError, resolveAlphaSearchCandidate } from '../tools/web-search/alpha-search/upstream.ts'; +import { defineStage, move } from '@floway-dev/pipeline'; +import type { ModelCandidate } from '@floway-dev/provider'; + +/** + * Resolves the operator's pinned search upstream into the attempt the ending dials. A + * misconfigured pin is the operator's to see with its stack, the way the gateway surfaces any + * internal failure; only a model the key cannot reach is the client's answer, and that answer + * is the same whether the model is missing or withheld. + */ +export const resolveSearchUpstream = (pinned: PinnedSearchUpstream) => defineStage< + Fields, + Fields<'route.attempt'>, + Fields<'response.search.alphaSearch' | 'response.usage.billable' | 'response.http.headers' | 'response.http.status'>, + Fields<'response.search.alphaSearch' | 'response.usage.billable' | 'response.http.headers' | 'response.http.status'>, + Fields<'response.search.alphaSearch' | 'response.usage.billable' | 'response.http.headers' | 'response.http.status'>, + GatewayServices +>({ + name: 'resolveSearchUpstream', + through: { + request: { needs: [], consumes: [], provides: ['route.attempt'] }, + response: { needs: [], consumes: [], provides: [] }, + }, + return: { provides: ['response.search.alphaSearch', 'response.usage.billable', 'response.http.headers', 'response.http.status'] }, + execute: async (facts, next, use) => { + let candidate: ModelCandidate; + try { + candidate = await resolveAlphaSearchCandidate({ + config: pinned, + subject: use.gateway.user, + upstreamIds: use.gateway.upstreamIds, + scheduler: use.gateway.backgroundScheduler, + runtimeLocation: use.gateway.runtimeLocation, + }); + } catch (error) { + if (!(error instanceof AlphaSearchModelNotFoundError)) throw error; + return move({ + ...facts, + 'response.search.alphaSearch': { status: 404, message: error.message }, + // Nothing was called, so nothing is billable and there are no upstream headers. + 'response.usage.billable': [], + 'response.http.headers': [], + 'response.http.status': 404, + }); + } + return await next({ ...facts, 'route.attempt': move(use.rememberCandidates([candidate])[0]!) }); + }, +}); diff --git a/packages/gateway/src/data-plane/alpha-search/routes.ts b/packages/gateway/src/data-plane/alpha-search/routes.ts index 95c0d58331..0f3cbd9029 100644 --- a/packages/gateway/src/data-plane/alpha-search/routes.ts +++ b/packages/gateway/src/data-plane/alpha-search/routes.ts @@ -1,170 +1,26 @@ -// Codex `/alpha/search` compatibility endpoint. The private request carries -// model/session context plus a command object; the response is -// `{ encrypted_output?, output, results? }`. -// https://github.com/openai/codex/blob/2e1607ee2fa8099a233df7437adee5f16a741905/codex-rs/codex-api/src/search.rs#L8-L29 -// https://github.com/openai/codex/blob/2e1607ee2fa8099a233df7437adee5f16a741905/codex-rs/codex-api/src/search.rs#L297-L305 +// Codex `/alpha/search` compatibility endpoint. The protocol itself — the request +// schema, the response shape, and the projection of Codex's settings onto the +// gateway's search filters — lives in `protocol.ts`; the chain that serves it is +// `pipeline.ts`, and `http.ts` is the seam between the two. +// // Clients append `alpha/search` to an OpenAI-compatible provider base. The // aliases below cover Floway's general root and `/v1` base conventions. // https://github.com/openai/codex/blob/2e1607ee2fa8099a233df7437adee5f16a741905/codex-rs/codex-api/src/endpoint/search.rs#L31-L47 // -// In the default mode, Floway executes supported commands through the general -// configured search provider and renders a local `{ encrypted_output: null, -// output }` response. Passthrough mode instead returns the selected Codex or -// Custom provider response verbatim, preserving its optional structured data. -// -// The shared data-plane auth middleware guards every alias; this handler reads -// the resolved API key for per-key search-usage accounting. +// The shared data-plane auth middleware guards every alias; the handler reads the +// resolved API key through the run's own prologue. import type { Hono } from 'hono'; -import { z } from 'zod'; -import { type AuthVars, apiKeyFromContext, effectiveUpstreamIdsFromContext, userFromContext } from '../../middleware/auth.ts'; -import { type CtxWithJson, zValidator } from '../../middleware/zod-validator.ts'; -import { backgroundSchedulerFromContext } from '../../runtime/background.ts'; -import { getRuntimeLocation } from '../../runtime/runtime-info.ts'; +import { alphaSearch } from './http.ts'; +import type { AuthVars } from '../../middleware/auth.ts'; import { mountPublicRoute } from '../public-route.ts'; -import { passthroughApiError } from '../shared/passthrough-serve.ts'; -import { relayFetchedResponse } from '../tools/web-search/alpha-search/relay-response.ts'; -import { AlphaSearchModelNotFoundError, resolveAlphaSearchDispatcher } from '../tools/web-search/alpha-search/upstream.ts'; -import type { AlphaSearchDispatcher } from '../tools/web-search/alpha-search/upstream.ts'; -import { loadWebSearchConfig } from '../tools/web-search/config.ts'; -import { assertLocalWebSearchSupport, executeOperationToText, isSearchResponseLength, maxResultsForContextSize, parseWebSearchOperations, startBatchFetch, UnsupportedLocalWebSearchFeatureError, type WebSearchExecutionSession, type WebSearchFilters } from '../tools/web-search/operations.ts'; -import { resolveConfiguredWebSearchProvider } from '../tools/web-search/provider.ts'; -import type { ConfiguredWebSearchProvider } from '../tools/web-search/types.ts'; import { PUBLIC_DATA_PLANE_ROUTES } from '@floway-dev/protocols/common'; -const domainListSchema = z.array(z.string()); - -// This is OpenAI Codex's complete SearchSettings shape. The loose object keeps -// future fields intact for passthrough; local providers consume the routing -// fields they implement while accepting Codex metadata such as allowed_callers. -// https://github.com/openai/codex/blob/2f19a57704fb7b1db032bc38cf995034254eaebb/codex-rs/codex-api/src/search.rs#L215-L295 -const searchSettingsSchema = z.looseObject({ - filters: z.looseObject({ - allowed_domains: domainListSchema.optional(), - blocked_domains: domainListSchema.optional(), - }).optional(), - user_location: z.looseObject({ - type: z.literal('approximate').optional(), - city: z.string().optional(), - region: z.string().optional(), - country: z.string().optional(), - timezone: z.string().optional(), - }).optional(), - search_context_size: z.enum(['low', 'medium', 'high']).optional(), - image_settings: z.looseObject({ - max_results: z.number().int().nonnegative().optional(), - caption: z.boolean().optional(), - }).optional(), - allowed_callers: z.array(z.enum(['direct', 'shell', 'code_interpreter'])).optional(), - external_web_access: z.union([ - z.boolean(), - z.enum(['cached', 'indexed', 'live']), - ]).optional(), -}); - -// `commands` is validated only as "an object" — the per-kind arrays are -// parsed by the shared command engine. `looseObject` preserves every OpenAI -// command and nested parameter so passthrough stays lossless and the local -// capability gate can reject unimplemented fields explicitly. -const alphaSearchRequestSchema = z.looseObject({ - commands: z.looseObject({}).optional(), - settings: searchSettingsSchema.optional(), -}); - -type AlphaSearchRequest = z.infer; - -const filtersFromSettings = (settings: AlphaSearchRequest['settings']): WebSearchFilters => { - const filters: WebSearchFilters = { - maxResults: maxResultsForContextSize(settings?.search_context_size), - }; - if (settings?.filters?.allowed_domains) filters.allowedDomains = settings.filters.allowed_domains; - if (settings?.filters?.blocked_domains) filters.blockedDomains = settings.filters.blocked_domains; - const loc = settings?.user_location; - if (loc && (loc.city !== undefined || loc.region !== undefined || loc.country !== undefined || loc.timezone !== undefined)) { - filters.userLocation = { - ...(loc.city !== undefined ? { city: loc.city } : {}), - ...(loc.region !== undefined ? { region: loc.region } : {}), - ...(loc.country !== undefined ? { country: loc.country } : {}), - ...(loc.timezone !== undefined ? { timezone: loc.timezone } : {}), - }; - } - return filters; -}; - -const alphaSearch = async (c: CtxWithJson): Promise => { - const body = c.req.valid('json'); - const webSearchConfig = await loadWebSearchConfig(); - if (webSearchConfig.passthroughOpenAiSearch.enabled) { - let dispatcher: AlphaSearchDispatcher; - try { - dispatcher = await resolveAlphaSearchDispatcher({ - config: webSearchConfig.passthroughOpenAiSearch, - subject: userFromContext(c), - upstreamIds: effectiveUpstreamIdsFromContext(c), - scheduler: backgroundSchedulerFromContext(c), - runtimeLocation: getRuntimeLocation(c.req.raw), - }); - } catch (error) { - // The configured model is private; missing models and grants share a response. - if (error instanceof AlphaSearchModelNotFoundError) return passthroughApiError(c, error.message, 404); - throw error; - } - const headers = new Headers(); - const turnMetadata = c.req.header('x-codex-turn-metadata'); - if (turnMetadata !== undefined) headers.set('x-codex-turn-metadata', turnMetadata); - const response = await dispatcher(body, c.req.raw.signal, headers); - return relayFetchedResponse(response); - } - - try { - assertLocalWebSearchSupport(body.commands ?? {}); - } catch (error) { - if (error instanceof UnsupportedLocalWebSearchFeatureError) { - return c.json({ encrypted_output: null, output: error.message }); - } - throw error; - } - - const responseLength = body.commands?.response_length; - let configuredProvider: Promise | undefined; - const session: WebSearchExecutionSession = { - ...(isSearchResponseLength(responseLength) ? { responseLength } : {}), - getProvider: () => { - configuredProvider ??= Promise.resolve(resolveConfiguredWebSearchProvider(webSearchConfig)); - return configuredProvider; - }, - filters: filtersFromSettings(body.settings), - apiKeyId: apiKeyFromContext(c).id, - pageCache: new Map(), - // Codex renders `output` as plain text; the search-action sources list - // is an OpenAI Responses wire concern with no place here. - includeSearchActionSources: false, - signal: c.req.raw.signal, - }; - - const parsed = parseWebSearchOperations(body.commands ?? {}); - if (parsed.kind !== 'ops' || parsed.ops.length === 0) { - return c.json({ - encrypted_output: null, - output: 'No web search commands were provided. Populate at least one of `search_query`, `open`, or `find`.', - }); - } - - // One batched provider.fetchPage covers every open/find URL; each op then - // renders its own text block. The shared parser's canonical order is - // search_query → open → find, preserving array order within each command - // kind. - const batch = await startBatchFetch(parsed, session); - const blocks = await Promise.all(parsed.ops.map(op => executeOperationToText(op, session, batch))); - - return c.json({ encrypted_output: null, output: blocks.join('\n\n') }); -}; - type AlphaSearchRoute = typeof PUBLIC_DATA_PLANE_ROUTES.alphaSearch | typeof PUBLIC_DATA_PLANE_ROUTES.codexAlphaSearch; export const mountAlphaSearchRoute = (app: Hono<{ Variables: AuthVars }>, route: AlphaSearchRoute) => { - mountPublicRoute(route, (method, path) => app.on(method, path, zValidator('json', alphaSearchRequestSchema), alphaSearch)); + mountPublicRoute(route, (method, path) => app.on(method, path, alphaSearch)); }; export const mountAlphaSearchRoutes = (app: Hono<{ Variables: AuthVars }>) => { diff --git a/packages/gateway/src/data-plane/alpha-search/services.ts b/packages/gateway/src/data-plane/alpha-search/services.ts new file mode 100644 index 0000000000..21cf82f1f8 --- /dev/null +++ b/packages/gateway/src/data-plane/alpha-search/services.ts @@ -0,0 +1,9 @@ +import type { GatewayServices } from '../pipeline/services.ts'; +import type { ConfiguredWebSearchProvider } from '../tools/web-search/types.ts'; + +/** The configured search backend is reached through a resolver rather than a fact: it holds + * live handles, and the operator's provider credential is what builds one — so nothing about + * it is dumpable and none of it belongs in the record. */ +export interface SearchServices extends GatewayServices { + readonly searchProvider: () => Promise; +} diff --git a/packages/gateway/src/data-plane/chat/anthropic-messages/respond.ts b/packages/gateway/src/data-plane/chat/anthropic-messages/respond.ts index 52cacad386..c7969ea127 100644 --- a/packages/gateway/src/data-plane/chat/anthropic-messages/respond.ts +++ b/packages/gateway/src/data-plane/chat/anthropic-messages/respond.ts @@ -104,7 +104,7 @@ const observeAnthropicMessagesFrames = async function* ( ctx: GatewayCtx, ) { for await (const frame of frames) { - ctx.dump?.frame(frame); + await ctx.dump?.frame(frame); const failed = frame.type === 'event' && frame.event.type === 'error'; if (failed) state.failed = true; if (isAnthropicMessagesTerminalFrame(frame) && !failed) state.completed = true; @@ -123,7 +123,7 @@ const anthropicMessagesSseFrames = async function* (frames: AsyncIterable>, state: SourceStreamState, ctx: GatewayCtx) { const completion = createGeminiGenerateContentCandidateCompletion(); for await (const frame of frames) { - ctx.dump?.frame(frame); + await ctx.dump?.frame(frame); const failed = frame.type === 'event' && isGeminiGenerateContentErrorEvent(frame.event); if (failed) state.failed = true; if (frame.type === 'event') completion.observe(frame.event); @@ -196,7 +196,7 @@ const geminiGenerateContentSseFrames = async function* (frames: AsyncIterable>, state: SourceStreamState, ctx: GatewayCtx) { for await (const frame of frames) { - ctx.dump?.frame(frame); + await ctx.dump?.frame(frame); const failed = isOpenAIChatCompletionsFailureFrame(frame); if (failed) state.failed = true; if (isOpenAIChatCompletionsTerminalFrame(frame) && !failed) state.completed = true; @@ -121,7 +121,7 @@ const openaiChatCompletionsSseFrames = async function* (frames: AsyncIterable ({ type: 'upload' as const, - file: new File([source.bytes], `image_${index}.${editFileExt(source.mimeType)}`, { type: source.mimeType }), + file: { bytes: new Uint8Array(source.bytes), name: `image_${index}.${editFileExt(source.mimeType)}`, type: source.mimeType }, })); const maskFile = mask === undefined ? undefined - : new File([mask.bytes], `mask.${editFileExt(mask.mimeType)}`, { type: mask.mimeType }); + : { bytes: new Uint8Array(mask.bytes), name: `mask.${editFileExt(mask.mimeType)}`, type: mask.mimeType }; return { images, ...(maskFile === undefined ? {} : { mask: { type: 'upload' as const, file: maskFile } }), diff --git a/packages/gateway/src/data-plane/chat/openai-responses/respond.ts b/packages/gateway/src/data-plane/chat/openai-responses/respond.ts index fc8250be84..278c179060 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/respond.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/respond.ts @@ -151,7 +151,7 @@ const internalOpenAIResponsesErrorResponse = (status: number, error: InternalDeb const observeOpenAIResponsesFrames = async function* (frames: AsyncIterable>, state: SourceStreamState, ctx: GatewayCtx) { for await (const frame of frames) { - ctx.dump?.frame(frame); + await ctx.dump?.frame(frame); if (frame.type === 'done') continue; const failed = frame.event.type === 'error' || frame.event.type === 'response.failed'; const terminal = isOpenAIResponsesTerminalEvent(frame.event); @@ -181,13 +181,13 @@ const openaiResponsesSseFrames = async function* (frames: AsyncIterable({ type: 'response.failed', response: { ...announced, status: 'failed', error: failure } }); - ctx.dump?.frame(failedFrame); + await ctx.dump?.frame(failedFrame); yield openaiResponsesProtocolFrameToSSEFrame(failedFrame); } } diff --git a/packages/gateway/src/data-plane/chat/openai-responses/websocket.ts b/packages/gateway/src/data-plane/chat/openai-responses/websocket.ts index 7776ead410..85be34490e 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/websocket.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/websocket.ts @@ -6,7 +6,7 @@ import { OPENAI_RESPONSES_REASONING_INCLUDED_HEADER, OPENAI_RESPONSES_REASONING_ import { createOpenAIResponsesResponseId } from './response-id.ts'; import { PreviousResponseNotFoundError } from './serve-prep.ts'; import { openaiResponsesServe } from './serve.ts'; -import type { DumpAccumulator } from '../../../dump/accumulator.ts'; +import type { TurnDump } from '../../../dump/turn-dump.ts'; import { apiKeyFromContext, authenticateApiKey, type AuthedContext } from '../../../middleware/auth.ts'; import { backgroundSchedulerFromContext } from '../../../runtime/background.ts'; import { inboundHeaders } from '../../shared/inbound-headers.ts'; @@ -757,7 +757,7 @@ const observeOpenAIResponsesWebSocketFrames = async function* ( ctx: ChatGatewayCtx, ): AsyncGenerator> { for await (const frame of frames) { - ctx.dump?.frame(frame); + await ctx.dump?.frame(frame); if (frame.type === 'event') { const event = frame.event; const failed = event.type === 'error' || event.type === 'response.failed'; @@ -884,7 +884,7 @@ const sendError = ( status: number, error: Record, eventId?: string, - dump?: DumpAccumulator | null, + dump?: TurnDump | null, ): void => { sendJson(socket, { type: 'error', status, error }, eventId, dump); }; @@ -901,14 +901,14 @@ const sendOpenAIResponsesEvent = ( socket: OpenAIResponsesWebSocketSocket, event: ClientOpenAIResponsesStreamEvent, eventId?: string, - dump?: DumpAccumulator | null, + dump?: TurnDump | null, ): boolean => sendJson(socket, event, eventId, dump); const sendJson = ( socket: OpenAIResponsesWebSocketSocket, value: unknown, eventId?: string, - dump?: DumpAccumulator | null, + dump?: TurnDump | null, ): boolean => { if (socket.readyState !== 1) return false; const payload = eventId === undefined || !value || typeof value !== 'object' diff --git a/packages/gateway/src/data-plane/chat/shared/execute-translation.ts b/packages/gateway/src/data-plane/chat/shared/execute-translation.ts index 50ea24587a..233d009e52 100644 --- a/packages/gateway/src/data-plane/chat/shared/execute-translation.ts +++ b/packages/gateway/src/data-plane/chat/shared/execute-translation.ts @@ -1,4 +1,5 @@ -import type { DumpAccumulator } from '../../../dump/accumulator.ts'; +import { DumpAccumulator } from '../../../dump/accumulator.ts'; +import type { TurnDump } from '../../../dump/turn-dump.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { ChatTargetApi, ExecuteResult } from '@floway-dev/provider'; import type { TranslateTrip, TranslateInputError } from '@floway-dev/translate'; @@ -11,10 +12,11 @@ export interface TranslationUpstreamCapture { // Stamp the target API before reading frames so an empty upstream stream retains its protocol identity. export const captureFromDump = ( - dump: DumpAccumulator | null, + dump: TurnDump | null, targetApi: ChatTargetApi, ): TranslationUpstreamCapture | undefined => { if (dump === null) return undefined; + if (!(dump instanceof DumpAccumulator)) throw new Error('Legacy chat translation requires an edge dump recording'); dump.setUpstreamTargetApi(targetApi); return { upstreamFrame: frame => dump.upstreamFrame(frame), diff --git a/packages/gateway/src/data-plane/decision/attempt.ts b/packages/gateway/src/data-plane/decision/attempt.ts deleted file mode 100644 index 20ca9a25ce..0000000000 --- a/packages/gateway/src/data-plane/decision/attempt.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { Context } from 'hono'; - -import type { GatewayCtx } from '../shared/gateway-ctx.ts'; -import { inboundHeaders } from '../shared/inbound-headers.ts'; -import { telemetryModelIdentity, upstreamPerformanceContext } from '../shared/telemetry/attribution.ts'; -import { buildUpstreamCallOptions } from '../shared/upstream-call-options.ts'; -import type { DecisionTarget } from '@floway-dev/protocols/common'; -import type { CanonicalDecisionRequest } from '@floway-dev/protocols/decision'; -import { providerModelOf } from '@floway-dev/provider'; -import type { ModelCandidate, PerformanceTelemetryContext, ProviderDecisionCallResult, TelemetryModelIdentity } from '@floway-dev/provider'; - -export interface DecisionAttemptResult { - readonly type: 'plain'; - readonly status: number; - readonly response: Response; - readonly target: DecisionTarget; - readonly performance: PerformanceTelemetryContext; - readonly identity: TelemetryModelIdentity; -} - -export const decisionAttempt = async ( - c: Context, - ctx: GatewayCtx, - candidate: ModelCandidate, - request: CanonicalDecisionRequest, -): Promise => { - const model = providerModelOf(candidate); - const result: ProviderDecisionCallResult = await candidate.provider.instance.callDecision( - model, - request, - ctx.abortSignal, - buildUpstreamCallOptions(candidate, ctx, inboundHeaders(c)), - ); - return { - type: 'plain', - status: result.response.status, - response: result.response, - target: result.target, - performance: upstreamPerformanceContext(ctx, candidate, 'decision'), - identity: telemetryModelIdentity(candidate, result.modelKey), - }; -}; diff --git a/packages/gateway/src/data-plane/decision/call-upstream.ts b/packages/gateway/src/data-plane/decision/call-upstream.ts new file mode 100644 index 0000000000..81df8a9e6d --- /dev/null +++ b/packages/gateway/src/data-plane/decision/call-upstream.ts @@ -0,0 +1,124 @@ +import type { Fields } from './facts.ts'; +import type { UsageQuantities } from '../../repo/types.ts'; +import type { BillableEntity } from '../pipeline/facts.ts'; +import { providerEntry } from '../pipeline/provider-entry.ts'; +import { providerUsage } from '../pipeline/provider-usage.ts'; +import type { GatewayServices } from '../pipeline/services.ts'; +import { dialFailure, readUpstreamBody, spentBody, unreadableBody } from '../pipeline/upstream-body.ts'; +import { telemetryModelIdentity, upstreamPerformanceContext } from '../shared/telemetry/attribution.ts'; +import { exchangeResponse } from '@floway-dev/http/pipeline'; +import { defineStage, move } from '@floway-dev/pipeline'; +import { isJsonObject, parseDecimalString, parseJSONWithRawNumbers, upstreamErrorMessage } from '@floway-dev/protocols/common'; +import { parseDecisionUsage } from '@floway-dev/protocols/decision'; +import { providerModelOf } from '@floway-dev/provider'; +import type { ProviderOperationPayloads, ProviderRequest, ProviderResponse } from '@floway-dev/provider'; + +/** Decision models are billed on input tokens only, which is also the one pricing input. */ +const metered = (usage: { inputTokens?: number }): Pick => { + if (usage.inputTokens === undefined) return { quantities: {} }; + const quantities: UsageQuantities = { input_tokens: parseDecimalString(String(usage.inputTokens)) }; + return { quantities, pricingFacts: { inputTokens: usage.inputTokens } }; +}; + +/** + * The ending. It dials, reads the upstream's body with its numbers kept as their source tokens, + * and provides the answer and what the call is billable for. A failure is a value, so an + * earlier stage can fail over it. + */ +export const callDecisionUpstream = defineStage< + Fields<'request.decision.canonical' | 'route.attempt' | 'ingress.http.headers'>, + ProviderRequest, + ProviderResponse, + Fields<'response.decision.answer' | 'response.decision.targetProtocol' | 'response.http.status' | 'response.http.headers' | 'response.http.body' | 'response.usage.billable'>, + GatewayServices +>({ + name: 'callDecisionUpstream', + into: { + request: { needs: ['request.decision.canonical', 'route.attempt', 'ingress.http.headers'], consumes: [], provides: ['request.provider.model', 'request.provider.payload', 'request.http.callId', 'request.http.headers'] }, + response: { + needs: ['response.http.exchange', 'response.provider.modelKey', 'response.provider.called', 'response.provider.previousCalls', 'response.http.body'], + consumes: ['response.http.exchange', 'response.provider.modelKey', 'response.provider.called', 'response.provider.previousCalls'], + provides: ['response.decision.answer', 'response.decision.targetProtocol', 'response.http.status', 'response.http.headers', 'response.http.body', 'response.usage.billable'], + }, + }, + execute: async (facts, next, use) => { + const candidate = use.resolveAttempt(facts['route.attempt']); + // The narrowing kept only candidates whose provider named a dialect, so this holds for + // every attempt; it is what the request was serialized in and what the answer is read as. + const target = providerModelOf(candidate).decisionTarget; + if (target === undefined) { + throw new Error(`${candidate.provider.upstreamId} serves decision for ${candidate.model.id} without a target dialect`); + } + // Attribution is set before the dial, so an attempt that never completes still names the + // candidate it was made against rather than the one tried before it. + use.gateway.attempt.telemetry = upstreamPerformanceContext(use.gateway, candidate, 'decision'); + + const pipeline = candidate.provider.pipelines.decision; + if (pipeline === undefined) throw new Error(`Provider ${candidate.provider.kind} has no decision pipeline`); + const back = await next(providerEntry(facts, candidate, facts['request.decision.canonical']), pipeline); + const { 'response.http.exchange': exchange, 'response.provider.modelKey': modelKey, 'response.provider.called': wasCalled, 'response.provider.previousCalls': _previousCalls, ...rest } = back; + if (exchange.type === 'transportFailure') { + return move({ + ...rest, + 'response.decision.answer': dialFailure(exchange.error), + 'response.decision.targetProtocol': target.protocol, + 'response.http.status': 502, + 'response.http.headers': [], + 'response.usage.billable': providerUsage(candidate, back, []), + }); + } + const response = exchangeResponse(exchange); + const identity = telemetryModelIdentity(candidate, modelKey); + const headers = [...response.headers]; + spentBody(exchange.body); + const body = await readUpstreamBody(response, parseJSONWithRawNumbers); + // The upstream was called and reported nothing, which is a different situation from + // reporting zero — so the entity is present with no quantities. + const unreported = wasCalled ? [{ identity, quantities: {} }] : []; + + if (!response.ok) { + await use.log.warn('upstream refused', { status: response.status }); + return move({ + ...rest, + 'response.decision.answer': { + status: response.status, + message: upstreamErrorMessage(body.json) ?? body.text, + ...('json' in body ? { body: body.json } : {}), + }, + 'response.decision.targetProtocol': target.protocol, + 'response.http.status': response.status, + 'response.http.headers': headers, + 'response.usage.billable': providerUsage(candidate, back, unreported), + }); + } + + if (!isJsonObject(body.json)) { + return move({ + ...rest, + 'response.decision.answer': unreadableBody(response, body, 'the decision protocol'), + 'response.decision.targetProtocol': target.protocol, + 'response.http.status': response.status, + 'response.http.headers': headers, + 'response.usage.billable': providerUsage(candidate, back, unreported), + }); + } + + // A usage block the reader cannot make sense of is a report we cannot parse, which from + // here is no report; it does not make the answer itself unreadable. + let usage: { inputTokens?: number }; + try { + usage = parseDecisionUsage(body.json); + } catch (error) { + await use.log.warn('upstream reported usage the decision protocol cannot read', { error: String(error) }); + usage = {}; + } + return move({ + ...rest, + 'response.decision.answer': { body: body.json }, + 'response.decision.targetProtocol': target.protocol, + 'response.http.status': response.status, + 'response.http.headers': headers, + 'response.usage.billable': providerUsage(candidate, back, wasCalled ? [{ identity, ...metered(usage) }] : []), + }); + }, +}); diff --git a/packages/gateway/src/data-plane/decision/emit.ts b/packages/gateway/src/data-plane/decision/emit.ts new file mode 100644 index 0000000000..918bc67749 --- /dev/null +++ b/packages/gateway/src/data-plane/decision/emit.ts @@ -0,0 +1,63 @@ +import type { Fields } from './facts.ts'; +import { isFailure } from '../pipeline/facts.ts'; +import { isForwardableUpstreamHeader } from '../shared/upstream-response.ts'; +import { defineStage, move } from '@floway-dev/pipeline'; +import { renderErrorEnvelope, type JsonObject } from '@floway-dev/protocols/common'; +import { parseDecisionResponse, renderDecisionResponse } from '@floway-dev/protocols/decision'; + +/** + * The outermost edge. A client on the upstream's own dialect is answered with the upstream's + * own document, vendor extensions and number tokens included; a client on the other dialect is + * answered with the canonical reading of it, rendered into its own. + */ +export const emitDecision = defineStage< + Fields<'ingress.decision.sourceProtocol'>, + Fields<'ingress.decision.sourceProtocol'>, + Fields<'ingress.decision.sourceProtocol' | 'response.decision.answer' | 'response.decision.targetProtocol' | 'response.http.status' | 'response.http.headers'>, + Fields<'response.decision.rendered' | 'response.http.status' | 'response.http.headers'> +>({ + name: 'emitDecision', + through: { + request: { needs: ['ingress.decision.sourceProtocol'], consumes: [], provides: [] }, + response: { + needs: ['response.decision.answer', 'response.http.headers', 'response.http.status'], + consumes: ['response.decision.answer', 'response.decision.targetProtocol', 'response.http.headers'], + provides: ['response.decision.rendered', 'response.http.status', 'response.http.headers'], + }, + }, + execute: async (facts, next) => { + const back = await next(facts); + const { 'response.decision.answer': answer, 'response.decision.targetProtocol': target, 'response.http.headers': headers, ...rest } = back; + // A filter that removed nothing hands the same array on, so the record shows no change + // where none happened. + const forwardable = headers.filter(([name]) => isForwardableUpstreamHeader(name)); + const forClient = forwardable.length === headers.length ? headers : move(forwardable); + if (isFailure(answer)) { + return { + ...rest, + 'response.http.headers': forClient, + 'response.decision.rendered': move(renderErrorEnvelope(answer.message, answer.body)), + 'response.http.status': answer.status, + }; + } + const source = back['ingress.decision.sourceProtocol']; + if (source === target) { + return { ...rest, 'response.http.headers': forClient, 'response.http.status': back['response.http.status'], 'response.decision.rendered': answer.body }; + } + // The upstream answered and the gateway cannot put that answer in the client's dialect — + // a refusal one dialect can express and the other cannot, or an answer missing the detail + // both contracts require. That is the gateway failing to serve, so it is 502. + let rendered: JsonObject; + try { + rendered = renderDecisionResponse(source, parseDecisionResponse(target, answer.body)); + } catch (error) { + return { + ...rest, + 'response.http.headers': forClient, + 'response.decision.rendered': move(renderErrorEnvelope(error instanceof Error ? error.message : String(error))), + 'response.http.status': 502, + }; + } + return { ...rest, 'response.http.headers': forClient, 'response.http.status': back['response.http.status'], 'response.decision.rendered': move(rendered) }; + }, +}); diff --git a/packages/gateway/src/data-plane/decision/facts.ts b/packages/gateway/src/data-plane/decision/facts.ts new file mode 100644 index 0000000000..3e009e6706 --- /dev/null +++ b/packages/gateway/src/data-plane/decision/facts.ts @@ -0,0 +1,26 @@ +import type { GatewayFacts, Failure } from '../pipeline/facts.ts'; +import type { DecisionProtocol, JsonObject } from '@floway-dev/protocols/common'; +import type { CanonicalDecisionRequest } from '@floway-dev/protocols/decision'; + +/** What an upstream answered, as JSON whose numbers keep their source tokens. It is held whole + * rather than parsed into the canonical form, because a same-dialect client is answered with + * the upstream's own document and only a translation needs the canonical one. */ +export interface DecisionAnswer { + readonly body: JsonObject; +} + +/** Decision's own keys. They extend the shared space and never merge into it, so a stage + * written against the gateway alone cannot name one. */ +export interface DecisionFacts extends GatewayFacts { + /** Which of the two decision dialects the client spoke. It belongs to the ingress and stays + * put: the answer is rendered back into it whatever the upstream spoke. */ + 'ingress.decision.sourceProtocol': DecisionProtocol; + 'request.decision.canonical': CanonicalDecisionRequest; + 'response.decision.answer': DecisionAnswer | Failure; + /** Which dialect the attempt spoke, so the edge knows what it is rendering from. */ + 'response.decision.targetProtocol': DecisionProtocol; + /** What the client is actually sent, in its own dialect. */ + 'response.decision.rendered': JsonObject; +} + +export type Fields = { [P in K]: DecisionFacts[P] }; diff --git a/packages/gateway/src/data-plane/decision/http.ts b/packages/gateway/src/data-plane/decision/http.ts new file mode 100644 index 0000000000..d24b293f60 --- /dev/null +++ b/packages/gateway/src/data-plane/decision/http.ts @@ -0,0 +1,63 @@ +// POST /v1/systemone and POST /v1/decisions, served through the pipeline. +// +// One family behind two routes. Which dialect the client spoke is the route it arrived on, so +// the mount passes it in and the handler carries it into the record as an ingress fact. +// Everything after the parse is stages. + +import type { Context } from 'hono'; + +import { decisionServePipeline } from './pipeline.ts'; +import { openPrologue, readIngress, serveThrough } from '../pipeline/serve.ts'; +import { finalizeGatewayResponse } from '../shared/gateway-ctx.ts'; +import { move } from '@floway-dev/pipeline'; +import { parseJSONWithRawNumbers, type DecisionProtocol } from '@floway-dev/protocols/common'; +import { parseDecisionRequest, type ParsedDecisionRequest } from '@floway-dev/protocols/decision'; + +// Raw-number parsing keeps a caller's own digits, so a same-dialect upstream is sent the +// numbers the caller wrote. +const readRequest = ( + sourceProtocol: DecisionProtocol, + bytes: Uint8Array, +): { type: 'ok'; parsed: ParsedDecisionRequest } | { type: 'invalid'; message: string } => { + let body: unknown; + try { + body = parseJSONWithRawNumbers(new TextDecoder().decode(bytes)); + } catch { + return { type: 'invalid', message: 'Decision request body must be valid JSON' }; + } + try { + return { type: 'ok', parsed: parseDecisionRequest(sourceProtocol, body) }; + } catch (error) { + return { type: 'invalid', message: error instanceof Error ? error.message : String(error) }; + } +}; + +export const decision = (sourceProtocol: DecisionProtocol) => async (c: Context): Promise => { + const ingress = await readIngress(c); + const result = readRequest(sourceProtocol, ingress.body.bytes); + if (result.type === 'invalid') { + // A request the gateway could not read never reaches a pipeline: there is no model to + // resolve and no attempt to make, so there is nothing for a run to record. + const refused = openPrologue(c, ingress, { wantsStream: false }); + refused.gateway.dump?.error('gateway'); + return finalizeGatewayResponse( + refused.gateway, + Response.json({ error: { message: result.message, type: 'api_error' } }, { status: 400 }), + ); + } + + const { model, request } = result.parsed; + const prologue = openPrologue(c, ingress, { wantsStream: false, model }); + return await serveThrough( + c, + prologue, + decisionServePipeline, + move({ + 'ingress.http.headers': prologue.headers, + 'ingress.decision.sourceProtocol': sourceProtocol, + 'request.decision.canonical': request, + 'serve.model': model, + }) as never, + facts => ({ body: facts['response.http.jsonBody']!, contentType: 'application/json' }), + ); +}; diff --git a/packages/gateway/src/data-plane/decision/pipeline.ts b/packages/gateway/src/data-plane/decision/pipeline.ts new file mode 100644 index 0000000000..713afe0a42 --- /dev/null +++ b/packages/gateway/src/data-plane/decision/pipeline.ts @@ -0,0 +1,25 @@ +import { callDecisionUpstream } from './call-upstream.ts'; +import { emitDecision } from './emit.ts'; +import type { Fields } from './facts.ts'; +import { narrowing } from './target.ts'; +import { isFailure } from '../pipeline/facts.ts'; +import { failover } from '../pipeline/failover.ts'; +import { resolveCandidates } from '../pipeline/resolve-candidates.ts'; +import { serializeClientJson } from '../pipeline/serialize-client-json.ts'; +import { writeSettlement } from '../pipeline/settlement.ts'; +import { compose, type Pipeline } from '@floway-dev/pipeline'; + +export const decisionServePipeline: Pipeline< + Fields<'ingress.http.headers' | 'ingress.decision.sourceProtocol' | 'request.decision.canonical' | 'serve.model'>, + Fields<'response.http.jsonBody' | 'response.decision.rendered' | 'response.http.status' | 'response.http.headers' | 'response.usage.billable'> +> = compose('decisionServe', [ + writeSettlement(handedUp => Number(handedUp['response.http.status']) >= 400), + serializeClientJson('response.decision.rendered'), + emitDecision, + resolveCandidates(narrowing), + failover({ + failed: handedUp => isFailure((handedUp as { 'response.decision.answer'?: unknown })['response.decision.answer']), + owns: ['response.http.body'], + }), + callDecisionUpstream, +]); diff --git a/packages/gateway/src/data-plane/decision/serve.ts b/packages/gateway/src/data-plane/decision/serve.ts deleted file mode 100644 index 2627713d74..0000000000 --- a/packages/gateway/src/data-plane/decision/serve.ts +++ /dev/null @@ -1,170 +0,0 @@ -import type { Context } from 'hono'; -import type { ContentfulStatusCode } from 'hono/utils/http-status'; - -import { decisionAttempt, type DecisionAttemptResult } from './attempt.ts'; -import type { UsageQuantities } from '../../repo/types.ts'; -import { backgroundSchedulerFromContext } from '../../runtime/background.ts'; -import { enumerateModelCandidates } from '../providers/resolution.ts'; -import { appendFailedUpstreams } from '../shared/failed-upstreams.ts'; -import { createGatewayCtxFromHono, finalizeGatewayResponse, type GatewayCtx } from '../shared/gateway-ctx.ts'; -import { iterateCandidates } from '../shared/iterate-candidates.ts'; -import { readRequestBody, takeRequestBody } from '../shared/request-body.ts'; -import { recordFailedRequest, recordPerformance, type PerformanceTelemetryContext } from '../shared/telemetry/performance.ts'; -import { recordUsage } from '../shared/telemetry/usage.ts'; -import { forwardUpstreamResponse } from '../shared/upstream-response.ts'; -import { parseDecimalString, parseJSONWithRawNumbers, type DecisionProtocol } from '@floway-dev/protocols/common'; -import { parseDecisionRequest, parseDecisionResponse, parseDecisionUsage, renderDecisionResponse, type ParsedDecisionRequest } from '@floway-dev/protocols/decision'; -import { httpResponseToResponse, ProviderModelsUnavailableError, providerModelOf, toInternalDebugError } from '@floway-dev/provider'; -import type { TelemetryModelIdentity } from '@floway-dev/provider'; - -const apiError = (c: Context, message: string, status: ContentfulStatusCode): Response => - c.json({ error: { message, type: 'api_error' } }, status); - -// Raw-number parsing keeps a caller's own digits — a same-dialect forward -// re-emits `raw` verbatim, so parsing to a number first would rewrite them. -const parseJson = (bytes: Uint8Array): unknown => { - try { - return parseJSONWithRawNumbers(new TextDecoder().decode(bytes)); - } catch { - throw new Error('Decision request body must be valid JSON'); - } -}; - -// Floway bills decision models on input tokens only. A provider that reports no -// usage still records the request through recordPerformance. -const settleDecision = ( - ctx: GatewayCtx, - performanceContext: PerformanceTelemetryContext, - identity: TelemetryModelIdentity, - usage: { inputTokens?: number } | undefined, - failed: boolean, -): void => { - const quantities: UsageQuantities = {}; - if (usage?.inputTokens !== undefined) quantities.input_tokens = parseDecimalString(String(usage.inputTokens)); - const pricingFacts = usage?.inputTokens === undefined ? {} : { inputTokens: usage.inputTokens }; - ctx.backgroundScheduler(recordUsage(ctx.apiKeyId, identity, quantities, pricingFacts).catch(error => { - console.error('Failed to record decision usage:', error); - })); - recordPerformance(ctx, performanceContext, failed, 0, performance.now()); -}; - -const unsupportedMessage = (model: string): string => `Model ${model} does not support decision.`; - -export const decision = (sourceProtocol: DecisionProtocol) => async (c: Context): Promise => { - const requestBody = await readRequestBody(c); - let parsedRequest: ParsedDecisionRequest; - try { - parsedRequest = parseDecisionRequest(sourceProtocol, parseJson(requestBody.bytes)); - } catch (error) { - const ctx = createGatewayCtxFromHono(c, { - wantsStream: false, - requestBody: takeRequestBody(requestBody), - backgroundScheduler: backgroundSchedulerFromContext(c), - }); - ctx.dump?.error('gateway'); - return finalizeGatewayResponse(ctx, apiError(c, error instanceof Error ? error.message : String(error), 400)); - } - - const { model, request } = parsedRequest; - const ctx = createGatewayCtxFromHono(c, { - wantsStream: false, - model, - requestBody: takeRequestBody(requestBody), - backgroundScheduler: backgroundSchedulerFromContext(c), - }); - - let terminal: DecisionAttemptResult | undefined; - let measuredUsage: { inputTokens?: number } | undefined; - let usageSettled = false; - try { - const { candidates, sawModel, failedUpstreams } = await enumerateModelCandidates({ - subject: ctx.user, - upstreamIds: ctx.upstreamIds, - randomizeUpstreams: ctx.randomizeUpstreams, - model, - kind: 'decision', - scheduler: ctx.backgroundScheduler, - runtimeLocation: ctx.runtimeLocation, - }); - if (candidates.length === 0) { - ctx.dump?.error('gateway'); - const message = sawModel - ? unsupportedMessage(model) - : `Model ${model} is not available on any configured upstream.`; - return finalizeGatewayResponse(ctx, apiError(c, appendFailedUpstreams(message, failedUpstreams), sawModel ? 400 : 404)); - } - - const routable = candidates.flatMap(candidate => { - const providerModel = providerModelOf(candidate); - return candidate.model.endpoints.decision === undefined || providerModel.decisionTarget === undefined - ? [] - : [{ candidate, target: providerModel.decisionTarget }]; - }); - if (routable.length === 0) { - ctx.dump?.error('gateway'); - return finalizeGatewayResponse(ctx, apiError(c, appendFailedUpstreams(unsupportedMessage(model), failedUpstreams), 400)); - } - terminal = await iterateCandidates( - routable.map(({ candidate }) => candidate), - 'decision', - ctx, - 'decision', - candidate => decisionAttempt(c, ctx, candidate, request), - ); - - if (!terminal.response.ok) { - ctx.dump?.error('upstream', terminal.identity.upstream); - settleDecision(ctx, terminal.performance, terminal.identity, undefined, true); - usageSettled = true; - return finalizeGatewayResponse(ctx, forwardUpstreamResponse(terminal.response)); - } - - const sameProtocol = sourceProtocol === terminal.target.protocol; - let upstreamBody: Record; - try { - // Raw-number parsing keeps each probability's source token, so a - // cross-dialect render writes the digits back unchanged. - upstreamBody = parseJSONWithRawNumbers(await terminal.response.clone().text()) as Record; - } catch (error) { - // A cross-dialect client cannot read the upstream's own body, so a - // translation failure must surface instead of being forwarded as success. - if (!sameProtocol) throw error; - console.warn( - `decision: failed to parse 2xx upstream body for ${model}; usage row will be request-only`, - error instanceof Error ? error.message : String(error), - ); - ctx.dump?.success(terminal.identity, null); - settleDecision(ctx, terminal.performance, terminal.identity, undefined, false); - usageSettled = true; - return finalizeGatewayResponse(ctx, forwardUpstreamResponse(terminal.response)); - } - measuredUsage = parseDecisionUsage(upstreamBody); - if (sameProtocol) { - ctx.dump?.success(terminal.identity, null); - settleDecision(ctx, terminal.performance, terminal.identity, measuredUsage, false); - usageSettled = true; - return finalizeGatewayResponse(ctx, forwardUpstreamResponse(terminal.response)); - } - const canonical = parseDecisionResponse(terminal.target.protocol, upstreamBody); - const rendered = renderDecisionResponse(sourceProtocol, canonical); - ctx.dump?.success(terminal.identity, null); - settleDecision(ctx, terminal.performance, terminal.identity, measuredUsage, false); - usageSettled = true; - return finalizeGatewayResponse(ctx, forwardUpstreamResponse(terminal.response, { body: JSON.stringify(rendered) })); - } catch (error) { - if (terminal !== undefined && !usageSettled) { - settleDecision(ctx, terminal.performance, terminal.identity, measuredUsage, true); - } else if (terminal === undefined) { - recordFailedRequest(ctx, ctx.attempt.telemetry); - } - if (error instanceof ProviderModelsUnavailableError) { - const forwarded = httpResponseToResponse(error.httpResponse); - if (forwarded) { - ctx.dump?.error('upstream'); - return finalizeGatewayResponse(ctx, forwarded); - } - } - ctx.dump?.failed(error); - return finalizeGatewayResponse(ctx, c.json({ error: toInternalDebugError(error) }, 502)); - } -}; diff --git a/packages/gateway/src/data-plane/decision/target.ts b/packages/gateway/src/data-plane/decision/target.ts new file mode 100644 index 0000000000..3926626901 --- /dev/null +++ b/packages/gateway/src/data-plane/decision/target.ts @@ -0,0 +1,14 @@ +import { providerModelOf, type ModelCandidate } from '@floway-dev/provider'; + +/** A candidate without a decision endpoint, or without a dialect to speak on it, is not a + * candidate. The dialect belongs to the provider, so a model that has none cannot be served. */ +export const narrowing = { + kind: 'decision' as const, + reject: (candidate: ModelCandidate) => + candidate.model.endpoints.decision === undefined || providerModelOf(candidate).decisionTarget === undefined + ? 'the upstream does not expose a decision endpoint' + : null, + unsupported: (model: string) => `Model ${model} does not support decision.`, + refuse: (status: number, message: string) => ({ 'response.decision.answer': { status, message } }), + refuses: ['response.decision.answer'] as const, +}; diff --git a/packages/gateway/src/data-plane/openai-audio/call-upstream.ts b/packages/gateway/src/data-plane/openai-audio/call-upstream.ts new file mode 100644 index 0000000000..1d658dc480 --- /dev/null +++ b/packages/gateway/src/data-plane/openai-audio/call-upstream.ts @@ -0,0 +1,248 @@ +import type { Fields, OpenAIAudioTranscriptionEvents, OpenAIAudioTranscriptionStreamOutcome } from './facts.ts'; +import type { UsageQuantities } from '../../repo/types.ts'; +import type { BillableEntity, Failure } from '../pipeline/facts.ts'; +import { providerEntry } from '../pipeline/provider-entry.ts'; +import { providerUsage } from '../pipeline/provider-usage.ts'; +import type { GatewayServices } from '../pipeline/services.ts'; +import { dialFailure, spentBody, retainReader } from '../pipeline/upstream-body.ts'; +import { upstreamPerformanceContext, telemetryModelIdentity } from '../shared/telemetry/attribution.ts'; +import { recordStream } from '@floway-dev/dump'; +import { exchangeResponse } from '@floway-dev/http/pipeline'; +import { defineStage, move, setRelease, defer, type Owned, type Logger, type Deferred } from '@floway-dev/pipeline'; +import { isEventStreamMediaType, eventFrame, parseSSEStream, parseDecimalString } from '@floway-dev/protocols/common'; +import { parseOpenAIAudioTranscription, parseOpenAIAudioTranscriptionUsage, isOpenAIAudioTranscriptionDoneEvent, parseOpenAIAudioTranscriptionStreamUsage, type OpenAIAudioTranscriptionResponseFormat, type CanonicalOpenAIAudioTranscription, type OpenAIAudioTranscriptionUsage } from '@floway-dev/protocols/openai-audio'; +import type { ProviderRequest, ProviderOperationPayloads, ProviderResponse, TelemetryModelIdentity } from '@floway-dev/provider'; + +/** + * The ending. It dials, reads what came back in the rendering the request asked for, and + * provides the answer, the raw HTTP response beneath it, and what the call is billable for. + * A failure is a value: a 429 here is what an earlier stage fails over, and so is a dial that + * never reached anyone. A 200 is never one of them — this endpoint carries the document the + * upstream sent rather than serializing one from what it read, so a body no reading could + * open is still that upstream's answer and there is nothing to try the next candidate for. + */ +export const callOpenAIAudioTranscriptionUpstream = defineStage< + Fields<'ingress.openaiAudioTranscription.responseFormat' | 'request.openaiAudioTranscription.form' | 'route.attempt' | 'ingress.http.headers'>, + ProviderRequest, + ProviderResponse, + Fields<'response.openaiAudioTranscription.canonical' | 'response.openaiAudioTranscription.mediaType' | 'response.openaiAudioTranscription.streamedOutcome'> + & { 'response.usage.billable': readonly BillableEntity[]; 'response.http.status': number; + 'response.http.headers': readonly (readonly [string, string])[]; + 'response.http.body': (ReadableStream & Owned) | null; }, + GatewayServices +>({ + name: 'callOpenAIAudioTranscriptionUpstream', + into: { + request: { needs: ['ingress.openaiAudioTranscription.responseFormat', 'request.openaiAudioTranscription.form', 'route.attempt', 'ingress.http.headers'], consumes: [], provides: ['request.provider.model', 'request.provider.payload', 'request.http.callId', 'request.http.headers'] }, + response: { needs: ['response.http.exchange', 'response.provider.modelKey', 'response.provider.called', 'response.provider.previousCalls', 'response.http.body'], consumes: ['response.http.exchange', 'response.provider.modelKey', 'response.provider.called', 'response.provider.previousCalls'], provides: ['response.openaiAudioTranscription.canonical', 'response.openaiAudioTranscription.mediaType', 'response.openaiAudioTranscription.streamedOutcome', 'response.usage.billable', 'response.http.status', 'response.http.headers', 'response.http.body'] }, + }, + execute: async (facts, next, use) => { + const candidate = use.resolveAttempt(facts['route.attempt']); + // Attribution is set before the dial, so an attempt that never completes still names the + // candidate it was made against rather than the one tried before it. + use.gateway.attempt.telemetry = upstreamPerformanceContext(use.gateway, candidate, 'audio_transcription'); + + const pipeline = candidate.provider.pipelines.openaiAudioTranscriptions; + if (pipeline === undefined) throw new Error(`Provider ${candidate.provider.kind} has no openaiAudioTranscriptions pipeline`); + const back = await next(providerEntry(facts, candidate, { entries: facts['request.openaiAudioTranscription.form'] }), pipeline); + const { 'response.http.exchange': exchange, 'response.provider.modelKey': modelKey, 'response.provider.called': wasCalled, 'response.provider.previousCalls': _previousCalls, ...rest } = back; + if (exchange.type === 'transportFailure') { + return move({ + ...rest, + 'response.openaiAudioTranscription.canonical': dialFailure(exchange.error), + 'response.openaiAudioTranscription.mediaType': null, + 'response.openaiAudioTranscription.streamedOutcome': null, + 'response.usage.billable': providerUsage(candidate, back, []), + 'response.http.status': 502, + 'response.http.headers': [], + 'response.http.body': spentBody(null), + }); + } + const result = { response: exchangeResponse(exchange), modelKey }; + + const identity = telemetryModelIdentity(candidate, result.modelKey); + const format = facts['ingress.openaiAudioTranscription.responseFormat']; + const status = result.response.status; + const mediaType = result.response.headers.get('content-type'); + const headers = move([...result.response.headers] as readonly (readonly [string, string])[]); + // An upstream that was called and reported nothing is a different situation from one + // that reported zero, so the entity is present with no quantities. + const called: readonly BillableEntity[] = wasCalled ? [{ identity, quantities: {} }] : []; + + if (!result.response.ok) { + await use.log.warn('upstream refused', { status }); + spentBody(exchange.body); + // An upstream error body is JSON like any other body, and reading it here is also what + // leaves a losing attempt with nothing open behind it. + return move({ + ...rest, + 'response.openaiAudioTranscription.canonical': await refusal(status, result.response), + 'response.openaiAudioTranscription.mediaType': mediaType, + 'response.openaiAudioTranscription.streamedOutcome': null, + 'response.usage.billable': providerUsage(candidate, back, called), + 'response.http.status': status, + 'response.http.headers': headers, + 'response.http.body': spentBody(exchange.body), + }); + } + + // A streamed answer is the one the client asked for with `stream`, and it is the media + // type that says one arrived: an upstream that ignores `stream` — `whisper-1` does — + // answers in the rendering `response_format` named instead. + // https://github.com/openai/openai-openapi/blob/db3e53198a66732cfe161339ea63bf36fc0137ad/openapi.yaml#L36325-L36336 + if (isEventStreamMediaType(mediaType)) { + if (result.response.body === null) { + return move({ + ...rest, + 'response.openaiAudioTranscription.canonical': { status: 502, message: 'Upstream returned a streaming response with no body.' }, + 'response.openaiAudioTranscription.mediaType': mediaType, + 'response.openaiAudioTranscription.streamedOutcome': null, + 'response.usage.billable': providerUsage(candidate, back, called), + 'response.http.status': status, + 'response.http.headers': headers, + 'response.http.body': spentBody(null), + }); + } + // What the upstream metered, and whether the transcript finished, are both observed + // here — closest to the upstream and on the protocol it spoke — by folding the events + // as they pass, so the reading costs one pass and the client's own stream drives it. + const metered = meterEvents(result.response.body, identity, use.gateway.abortSignal, use.log); + setRelease(exchange.body!, async () => { for await (const _event of metered.events) { /* drain */ } }); + return move({ + ...rest, + // SSE labels and data remain upstream-owned; parsing below only observes billing and completion. + 'response.openaiAudioTranscription.canonical': recordStream(metered.events, use.gateway.dump, eventFrame), + 'response.openaiAudioTranscription.mediaType': mediaType, + 'response.openaiAudioTranscription.streamedOutcome': defer(metered.outcome.then(outcome => ({ ...outcome, billable: providerUsage(candidate, back, outcome.billable) }))), + 'response.usage.billable': providerUsage(candidate, back, called), + 'response.http.status': status, + 'response.http.headers': headers, + // Releasing this body is reading those events to the end: they are one reader over + // one connection, and a second reader is not something a `ReadableStream` allows. + 'response.http.body': exchange.body, + }); + } + + // A 2xx body is the answer whether or not this endpoint could read it, because what the + // client is sent is the document that arrived and not something serialized from a parse. + // So there is nothing here to fail over from: the reading feeds the record and the usage + // row, and the bytes travel either way. + spentBody(exchange.body); + const read = await readTranscription(format, new Uint8Array(await result.response.arrayBuffer()), use.log); + return move({ + ...rest, + 'response.openaiAudioTranscription.canonical': read.canonical, + 'response.openaiAudioTranscription.mediaType': mediaType, + 'response.openaiAudioTranscription.streamedOutcome': null, + 'response.usage.billable': providerUsage(candidate, back, wasCalled ? [{ identity, quantities: billed(read.usage) }] : []), + 'response.http.status': status, + 'response.http.headers': headers, + 'response.http.body': spentBody(exchange.body), + }); + }, +}); + +const refusal = async (status: number, response: Response): Promise => { + const text = await response.text(); + try { + return { status, message: text, body: JSON.parse(text) as unknown }; + } catch { + // A refusal that is not JSON is still a refusal, and its text is what the client is + // told; there is simply no parsed body for a dump reader to open. + return { status, message: text }; + } +}; + +/** + * What a 2xx body was worth to this endpoint, in two readings that do not depend on each + * other. + * + * Neither can cost the client the answer: the document is carried, so a body no reading + * could open is still what goes back, and what a failed reading costs is the transcript in + * the record and whatever usage the body would have stated. Nor can either cost the other — + * an upstream whose usage block this gateway cannot model still had its transcript read, and + * one whose document it could not open is still billed for nothing rather than mis-billed. + */ +const readTranscription = async ( + format: OpenAIAudioTranscriptionResponseFormat, + document: Uint8Array, + log: Logger, +): Promise<{ readonly canonical: CanonicalOpenAIAudioTranscription; readonly usage: OpenAIAudioTranscriptionUsage | undefined }> => { + let canonical: CanonicalOpenAIAudioTranscription; + try { + canonical = parseOpenAIAudioTranscription(format, document); + } catch (error) { + await log.warn('failed to parse 2xx upstream body for /audio/transcriptions; forwarding it as it arrived', { error }); + return { canonical: { document }, usage: undefined }; + } + // Only an object rendering states usage: `text`, `srt` and `vtt` have nowhere to put it, + // and asking them for one is what would warn about every subtitle a client requests. + return { canonical, usage: canonical.raw === undefined ? undefined : await readUsage(() => parseOpenAIAudioTranscriptionUsage(canonical.raw), log) }; +}; + +/** A transcription states its usage in two places — the body of an object rendering and the + * terminal event of a stream — and either can state it in a shape this gateway cannot read. + * That upstream metered something, and the request is recorded saying exactly that: the + * entity, and no quantities. */ +const readUsage = async (read: () => OpenAIAudioTranscriptionUsage | undefined, log: Logger): Promise => { + try { + return read(); + } catch (error) { + await log.warn('invalid usage in 2xx upstream response; recording the request only', { error }); + return undefined; + } +}; + +interface MeteredEvents { + readonly events: OpenAIAudioTranscriptionEvents; + readonly outcome: Deferred; +} + +const meterEvents = ( + body: ReadableStream, + identity: TelemetryModelIdentity, + signal: AbortSignal | undefined, + log: Logger, +): MeteredEvents => { + let settle!: (outcome: OpenAIAudioTranscriptionStreamOutcome) => void; + // Declared as this run's own unfinished work, so the runner waits for it at teardown where + // it can see it rather than the reading being started and forgotten. + const outcome = defer(new Promise(resolve => { settle = resolve; })); + const events = retainReader((async function* () { + let usage: OpenAIAudioTranscriptionUsage | undefined; + let completed = false; + try { + for await (const frame of parseSSEStream(body, { signal })) { + const event: unknown = JSON.parse(frame.data); + if (isOpenAIAudioTranscriptionDoneEvent(event)) { + usage = await readUsage(() => parseOpenAIAudioTranscriptionStreamUsage(event), log); + completed = true; + yield frame; + // The transcript is complete, so there is nothing further to read. An upstream that + // holds the connection open past this point would otherwise keep the client's own + // stream open with it; returning here closes the read, which cancels the upstream. + return; + } + yield frame; + } + } finally { + // Reached however the events ended — the terminal one, a client that stopped reading, + // or a broken upstream — because what the upstream already metered is billable + // whatever happened to the downstream half. + settle({ billable: [{ identity, quantities: billed(usage) }], failed: !completed }); + } + })()); + return { events, outcome }; +}; + +const billed = (usage: OpenAIAudioTranscriptionUsage | undefined): UsageQuantities => { + if (usage === undefined) return {}; + if (usage.kind === 'duration') return { input_audio_seconds: parseDecimalString(String(usage.seconds)) }; + // Audio input is priced apart from text input, so what stays on the general input metric is + // what the upstream did not attribute to audio. + return { + input_tokens: parseDecimalString(String(usage.inputTokens - (usage.inputAudioTokens ?? 0))), + ...(usage.inputAudioTokens === undefined ? {} : { input_audio_tokens: parseDecimalString(String(usage.inputAudioTokens)) }), + output_tokens: parseDecimalString(String(usage.outputTokens)), + }; +}; diff --git a/packages/gateway/src/data-plane/openai-audio/emit.ts b/packages/gateway/src/data-plane/openai-audio/emit.ts new file mode 100644 index 0000000000..6f7f3b1fe4 --- /dev/null +++ b/packages/gateway/src/data-plane/openai-audio/emit.ts @@ -0,0 +1,69 @@ +import type { OpenAIAudioTranscriptionEvents, Fields } from './facts.ts'; +import { isFailure } from '../pipeline/facts.ts'; +import { isForwardableUpstreamHeader } from '../shared/upstream-response.ts'; +import { defineStage, move } from '@floway-dev/pipeline'; +import { renderErrorEnvelope } from '@floway-dev/protocols/common'; +import { renderOpenAIAudioTranscription, type CanonicalOpenAIAudioTranscription } from '@floway-dev/protocols/openai-audio'; + +const isEvents = (answer: CanonicalOpenAIAudioTranscription | OpenAIAudioTranscriptionEvents): answer is OpenAIAudioTranscriptionEvents => + Symbol.asyncIterator in answer; + +/** + * The outermost edge. Writes the answer back in the rendering the client asked for, and names + * a media type for the one body the gateway wrote out of nothing the upstream sent — its + * error envelope. Every other answer goes out under the media type it arrived with, because + * for a document that is carried rather than rewritten the upstream's label is the only true + * description there is. + * + * Streamed answers retain the upstream's parsed SSE frames. Reading their event payloads + * for usage must not replace labels or reserialize data seen by the client. + */ +export const emitOpenAIAudioTranscription = defineStage< + Fields<'ingress.openaiAudioTranscription.responseFormat'>, + Fields<'ingress.openaiAudioTranscription.responseFormat'>, + Fields<'ingress.openaiAudioTranscription.responseFormat' | 'response.openaiAudioTranscription.canonical' | 'response.openaiAudioTranscription.mediaType'> + & { 'response.http.status': number; 'response.http.headers': readonly (readonly [string, string])[] }, + Fields<'response.openaiAudioTranscription.rendered' | 'response.openaiAudioTranscription.mediaType'> + & { 'response.http.status': number; 'response.http.headers': readonly (readonly [string, string])[] } +>({ + name: 'emitOpenAIAudioTranscription', + through: { + request: { + needs: ['ingress.openaiAudioTranscription.responseFormat'], + consumes: [], + provides: [], + }, + response: { + needs: ['response.openaiAudioTranscription.canonical', 'response.openaiAudioTranscription.mediaType', 'response.http.headers', 'response.http.status'], + consumes: ['response.openaiAudioTranscription.canonical', 'response.http.headers'], + provides: ['response.openaiAudioTranscription.rendered', 'response.openaiAudioTranscription.mediaType', 'response.http.status', 'response.http.headers'], + }, + }, + execute: async (facts, next) => { + const back = await next(facts); + const { 'response.openaiAudioTranscription.canonical': answer, 'response.http.headers': headers, ...rest } = back; + // Vendor traces and quota state stay visible; what an intermediary must strip, and what + // would misdescribe a body this gateway serialized itself, does not. A filter that removed + // nothing hands the same array on, so the record shows no change where none happened. + const forwardable = headers.filter(([name]) => isForwardableUpstreamHeader(name)); + const forClient = forwardable.length === headers.length ? headers : move(forwardable); + + if (isFailure(answer)) { + return { + ...rest, + 'response.http.headers': forClient, + 'response.http.status': answer.status, + 'response.openaiAudioTranscription.mediaType': 'application/json', + 'response.openaiAudioTranscription.rendered': move(renderErrorEnvelope(answer.message, answer.body)), + }; + } + return { + ...rest, + 'response.http.headers': forClient, + 'response.http.status': back['response.http.status'], + 'response.openaiAudioTranscription.rendered': move(isEvents(answer) + ? answer + : renderOpenAIAudioTranscription(back['ingress.openaiAudioTranscription.responseFormat'], answer)), + }; + }, +}); diff --git a/packages/gateway/src/data-plane/openai-audio/facts.ts b/packages/gateway/src/data-plane/openai-audio/facts.ts new file mode 100644 index 0000000000..543ed864a3 --- /dev/null +++ b/packages/gateway/src/data-plane/openai-audio/facts.ts @@ -0,0 +1,72 @@ +import type { BillableEntity, GatewayFacts, Failure } from '../pipeline/facts.ts'; +import type { Deferred } from '@floway-dev/pipeline'; +import type { SseFrame } from '@floway-dev/protocols/common'; +import type { OpenAIAudioTranscriptionResponseFormat, CanonicalOpenAIAudioTranscription } from '@floway-dev/protocols/openai-audio'; + +export interface AudioUpload { + readonly name: string; + readonly type: string; + readonly lastModified: number; + readonly bytes: Uint8Array; +} + +export interface AudioFormEntry { + readonly name: string; + readonly value: string | AudioUpload; +} + +/** The answer while it is still the upstream's, one event at a time. It is a view and not a + * resource: what owns the connection is `response.http.body`, which is where release and + * failover's ownership both read. + * + * A view is a wrapper around the generator rather than the generator itself, which is what + * says where the resource is: the upstream's body at `response.http.body`, claimed with + * `own()`, and nothing else here. */ +export type OpenAIAudioTranscriptionEvents = AsyncIterable; + +/** What settling this run will be told once the events run out: what the upstream metered, + * and whether the transcript ever finished. */ +export interface OpenAIAudioTranscriptionStreamOutcome { + readonly billable: readonly BillableEntity[]; + /** An upstream that stopped before `transcript.text.done` answered 200 and then did not + * finish what it started, which is a failed request however much of the transcript + * reached the client. */ + readonly failed: boolean; +} + +/** OpenAI Audio Transcriptions' own keys, extending the shared space by intersection. */ +export interface OpenAIAudioTranscriptionFacts extends GatewayFacts { + /** Which of the six renderings the client asked for. It belongs to the ingress and stays + * put: the same value travels to the upstream inside the form, so the rendering the + * answer arrives in is the rendering the answer is written back in — and a text document + * does not say which of the three it is, so nothing else could decide. */ + 'ingress.openaiAudioTranscription.responseFormat': OpenAIAudioTranscriptionResponseFormat; + /** The multipart form as ordered semantic entries. The body is parsed before routing + * because field order is unconstrained, and every candidate builds a fresh body from + * these, so a retry never reuses a consumed one. Uploaded files carry their metadata and bytes as portable content values. */ + 'request.openaiAudioTranscription.form': readonly AudioFormEntry[]; + /** The one transcription, whichever rendering carried it — or the events it is arriving + * as, or the failure that came instead. A stream, a value and a failure sit at one key: + * telling them apart is reading a value, and each stage does that where it needs to. */ + 'response.openaiAudioTranscription.canonical': CanonicalOpenAIAudioTranscription | OpenAIAudioTranscriptionEvents | Failure; + /** What the answer goes out under. A media type is upstream-owned — OpenAI answers + * `text`, `srt` and `vtt` under one media type and other upstreams label them apart, and + * the document beneath it is the upstream's own either way — so the upstream's travels, + * and the edge names one only where the gateway wrote the body out of nothing the + * upstream sent. `null` is an upstream that declared none, and it stays `null`: labelling + * a body nobody described is a statement this gateway has no grounds to make. */ + 'response.openaiAudioTranscription.mediaType': string | null; + /** What the stream will have come to by the time the events run out, and `null` on every + * path that does not stream. A streamed transcription states its usage in the terminal + * event, which is after this run has answered — so the numbers cannot be in + * `response.usage.billable`, which says what had been reported when the ending stage + * handed up: the entity, and no quantities. Settling from this is the epilogue's job, + * after the drain. */ + 'response.openaiAudioTranscription.streamedOutcome': Deferred | null; + /** What the client is actually sent — a JSON object, the upstream's own document, or the + * SSE frames of a stream. The edge provides it, so a dump shows what the client received + * rather than the gateway's own reading of it. */ + 'response.openaiAudioTranscription.rendered': Record | Uint8Array | AsyncIterable; +} + +export type Fields = { [P in K]: OpenAIAudioTranscriptionFacts[P] }; diff --git a/packages/gateway/src/data-plane/openai-audio/http.ts b/packages/gateway/src/data-plane/openai-audio/http.ts index 9870faf64f..e88dd417dc 100644 --- a/packages/gateway/src/data-plane/openai-audio/http.ts +++ b/packages/gateway/src/data-plane/openai-audio/http.ts @@ -1,25 +1,28 @@ -// POST /v1/audio/transcriptions — buffered OpenAI-compatible multipart -// transcription. The full body is parsed before routing because multipart -// field order is unconstrained; providers receive ordered semantic entries -// and rebuild a fresh body per candidate. +// POST /v1/audio/transcriptions, served through the pipeline. +// +// The multipart body is read and parsed before routing because field order is unconstrained +// and every candidate builds a fresh body from the entries. What the handler decides for +// itself is written in that form: which of the six renderings the client asked for, and +// whether the request streams — the run has to be opened knowing the second. // https://github.com/openai/openai-openapi/blob/db3e53198a66732cfe161339ea63bf36fc0137ad/openapi.yaml#L714-L1040 import type { Context } from 'hono'; -import { respondOpenAIAudioTranscription } from './respond.ts'; -import { backgroundSchedulerFromContext } from '../../runtime/background.ts'; -import { createGatewayCtxFromHono, finalizeGatewayResponse } from '../shared/gateway-ctx.ts'; -import { passthroughApiError, passthroughServe } from '../shared/passthrough-serve.ts'; -import { readRequestBody, takeRequestBody } from '../shared/request-body.ts'; +import type { AudioFormEntry } from './facts.ts'; +import { openaiAudioTranscriptionServePipeline } from './pipeline.ts'; +import { isFrames, openPrologue, readIngress, serveThrough } from '../pipeline/serve.ts'; +import { finalizeGatewayResponse } from '../shared/gateway-ctx.ts'; +import { move } from '@floway-dev/pipeline'; import { isMultipartFormDataMediaType } from '@floway-dev/protocols/common'; -import type { OpenAIAudioTranscriptionFormEntry } from '@floway-dev/provider'; +import { parseOpenAIAudioTranscriptionResponseFormat, type OpenAIAudioTranscriptionResponseFormat } from '@floway-dev/protocols/openai-audio'; type PreparedOpenAIAudioTranscription = | { readonly type: 'ok'; readonly model: string; + readonly responseFormat: OpenAIAudioTranscriptionResponseFormat; readonly wantsStream: boolean; - readonly entries: readonly OpenAIAudioTranscriptionFormEntry[]; + readonly entries: readonly AudioFormEntry[]; } | { readonly type: 'invalid'; readonly message: string }; @@ -43,43 +46,80 @@ const prepareOpenAIAudioTranscription = async (bytes: Uint8Array, contentType: s if (files.length === 0 || files.some(file => !(file instanceof File))) { return { type: 'invalid', message: 'OpenAI Audio Transcriptions request body must include a file upload.' }; } + const declaredFormat = form.get('response_format'); + if (declaredFormat !== null && typeof declaredFormat !== 'string') { + return { type: 'invalid', message: 'OpenAI Audio Transcriptions response_format must be a text field.' }; + } + let responseFormat: OpenAIAudioTranscriptionResponseFormat; + try { + responseFormat = parseOpenAIAudioTranscriptionResponseFormat(declaredFormat ?? undefined); + } catch (error) { + return { type: 'invalid', message: error instanceof Error ? error.message : String(error) }; + } - const entries: OpenAIAudioTranscriptionFormEntry[] = []; + const entries: AudioFormEntry[] = []; for (const [name, value] of form.entries()) { - entries.push({ name, value }); + entries.push({ + name, value: typeof value === 'string' ? value : { + name: value.name, + type: value.type, + lastModified: value.lastModified, + bytes: new Uint8Array(await value.arrayBuffer()), + }, + }); } return { type: 'ok', model, + responseFormat, wantsStream: form.get('stream') === 'true', entries, }; }; +/** + * The epilogue: what the run answered with, as a response. + * + * It is `serveThrough` like every other family, and it took two things at the seam to make it + * one. A carried document goes out under the upstream's own media type, including the upstream + * that declared none — so `Rendered.contentType` carries a null and the seam answers without + * the header rather than inventing one. And a transcription's stream states its own outcome in + * its last event, which the family's meter reads because it is the one place that knows — so + * the deferred reading holds both what was billed and whether the stream finished, + * settled from one promise while the request is still live. + */ export const openaiAudioTranscriptions = async (c: Context): Promise => { - const requestBody = await readRequestBody(c); - const request = await prepareOpenAIAudioTranscription(requestBody.bytes, c.req.header('content-type')); - const ctx = createGatewayCtxFromHono(c, { - wantsStream: request.type === 'ok' ? request.wantsStream : false, - requestBody: takeRequestBody(requestBody), - backgroundScheduler: backgroundSchedulerFromContext(c), - }); + const ingress = await readIngress(c); + const request = await prepareOpenAIAudioTranscription(ingress.body.bytes, c.req.header('content-type')); if (request.type === 'invalid') { - ctx.dump?.error('gateway'); - return finalizeGatewayResponse(ctx, passthroughApiError(c, request.message, 400)); + // A request the gateway could not read never reaches a pipeline: there is no model to + // resolve and no attempt to make, so there is nothing for a run to record. + const refused = openPrologue(c, ingress, { wantsStream: false }); + refused.gateway.dump?.error('gateway'); + return finalizeGatewayResponse( + refused.gateway, + Response.json({ error: { message: request.message, type: 'api_error' } }, { status: 400 }), + ); } - ctx.dump?.requestedModel(request.model); - const response = await passthroughServe({ + const prologue = openPrologue(c, ingress, { wantsStream: request.wantsStream, model: request.model }); + + return await serveThrough( c, - ctx, - sourceApi: '/audio/transcriptions', - operation: 'audio_transcription', - model: request.model, - kind: 'transcription', - modelServesEndpoint: model => model.endpoints.openaiAudioTranscriptions !== undefined, - call: (provider, model, opts) => provider.instance.callOpenAIAudioTranscriptions(model, { entries: request.entries }, ctx.abortSignal, opts), - response: { format: 'strategy', respond: respondOpenAIAudioTranscription }, - }); - return finalizeGatewayResponse(ctx, response); + prologue, + openaiAudioTranscriptionServePipeline, + move({ + 'ingress.http.headers': prologue.headers, + 'ingress.openaiAudioTranscription.responseFormat': request.responseFormat, + 'request.openaiAudioTranscription.form': request.entries, + 'serve.model': request.model, + }) as never, + facts => { + const rendered = facts['response.openaiAudioTranscription.rendered']; + if (isFrames(rendered)) return { frames: rendered }; + // The upstream's own media type, or none where it declared none: a document this + // gateway carried rather than wrote is not one it can describe. + return { body: (rendered instanceof Uint8Array ? rendered : facts['response.http.jsonBody']!) as BodyInit, contentType: facts['response.openaiAudioTranscription.mediaType'] }; + }, + ); }; diff --git a/packages/gateway/src/data-plane/openai-audio/pipeline.ts b/packages/gateway/src/data-plane/openai-audio/pipeline.ts new file mode 100644 index 0000000000..6edc059069 --- /dev/null +++ b/packages/gateway/src/data-plane/openai-audio/pipeline.ts @@ -0,0 +1,28 @@ +import { callOpenAIAudioTranscriptionUpstream } from './call-upstream.ts'; +import { emitOpenAIAudioTranscription } from './emit.ts'; +import type { Fields } from './facts.ts'; +import { narrowing } from './target.ts'; +import { isFailure, type BillableEntity } from '../pipeline/facts.ts'; +import { failover } from '../pipeline/failover.ts'; +import { resolveCandidates } from '../pipeline/resolve-candidates.ts'; +import { serializeClientJson } from '../pipeline/serialize-client-json.ts'; +import { writeSettlement } from '../pipeline/settlement.ts'; +import { compose, type Pipeline } from '@floway-dev/pipeline'; + +export const openaiAudioTranscriptionServePipeline: Pipeline< + Fields<'ingress.http.headers' | 'ingress.openaiAudioTranscription.responseFormat' | 'request.openaiAudioTranscription.form' | 'serve.model'>, + Fields<'response.http.jsonBody' | 'response.openaiAudioTranscription.rendered' | 'response.openaiAudioTranscription.mediaType' | 'response.openaiAudioTranscription.streamedOutcome'> + & { 'response.http.status': number; 'response.usage.billable': readonly BillableEntity[]; + 'response.http.headers': readonly (readonly [string, string])[]; } +> = compose('openaiAudioTranscriptionServe', [ + writeSettlement(handedUp => Number(handedUp['response.http.status']) >= 400, 'response.openaiAudioTranscription.streamedOutcome'), + serializeClientJson('response.openaiAudioTranscription.rendered'), + emitOpenAIAudioTranscription, + resolveCandidates(narrowing), + failover({ + failed: handedUp => isFailure((handedUp as { 'response.openaiAudioTranscription.canonical'?: unknown })['response.openaiAudioTranscription.canonical']), + owns: ['response.http.body'], + pendingUsage: 'response.openaiAudioTranscription.streamedOutcome', + }), + callOpenAIAudioTranscriptionUpstream, +]); diff --git a/packages/gateway/src/data-plane/openai-audio/respond.ts b/packages/gateway/src/data-plane/openai-audio/respond.ts deleted file mode 100644 index dc838b760c..0000000000 --- a/packages/gateway/src/data-plane/openai-audio/respond.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { streamSSE } from 'hono/streaming'; - -import { measureOpenAIAudioTranscriptionUsage } from './usage.ts'; -import { passthroughApiError } from '../shared/passthrough-serve.ts'; -import type { PassthroughResponseStrategyContext } from '../shared/passthrough-serve.ts'; -import { type StreamCompletion, writeSSEFrames } from '../shared/sse.ts'; -import { settleUsageMeasurement } from '../shared/telemetry/settle.ts'; -import { requestOnlyUsageMeasurement } from '../shared/telemetry/usage.ts'; -import { forwardUpstreamHeaders, forwardUpstreamResponse } from '../shared/upstream-response.ts'; -import { eventFrame, isEventStreamMediaType, isJsonMediaType, parseSSEStream, sseCommentFrame } from '@floway-dev/protocols/common'; -import { isOpenAIAudioTranscriptionDoneEvent } from '@floway-dev/protocols/openai-audio'; - -const respondNonStreaming = async ({ ctx, sourceApi, response, performance, identity }: PassthroughResponseStrategyContext): Promise => { - let measurement = requestOnlyUsageMeasurement(); - if (isJsonMediaType(response.headers.get('content-type'))) { - let parsed: unknown; - try { - parsed = await response.clone().json(); - } catch (error) { - console.warn( - `audio-transcription: failed to parse 2xx upstream body for ${sourceApi}; usage row will be request-only`, - error instanceof Error ? error.message : String(error), - ); - } - if (parsed !== undefined) { - measurement = measureOpenAIAudioTranscriptionUsage(parsed, sourceApi); - } - } - ctx.dump?.success(identity, measurement.dumpTokenUsage); - settleUsageMeasurement(ctx, performance, identity, measurement, false); - return forwardUpstreamResponse(response, { defaultContentType: null }); -}; - -const respondStreaming = ({ c, ctx, sourceApi, response, performance, identity }: PassthroughResponseStrategyContext): Response => { - const upstreamBody = response.body; - if (!upstreamBody) { - ctx.dump?.failed(`${sourceApi} streaming upstream returned no body`); - settleUsageMeasurement(ctx, performance, identity, requestOnlyUsageMeasurement(), true); - forwardUpstreamHeaders(c, response.headers); - return passthroughApiError(c, 'Upstream returned a streaming response with no body.', 502); - } - forwardUpstreamHeaders(c, response.headers); - return streamSSE(c, async stream => { - let completion: StreamCompletion = 'error'; - let streamError: unknown; - let terminalEventSeen = false; - let measurement = requestOnlyUsageMeasurement(); - try { - const frames = (async function* () { - for await (const frame of parseSSEStream(upstreamBody, { signal: ctx.abortSignal })) { - let event: unknown; - try { - event = JSON.parse(frame.data) as unknown; - } catch (error) { - throw new Error(`Malformed upstream ${sourceApi} SSE JSON: ${frame.data}`, { cause: error }); - } - ctx.dump?.frame(eventFrame(event)); - if (isOpenAIAudioTranscriptionDoneEvent(event)) { - terminalEventSeen = true; - measurement = measureOpenAIAudioTranscriptionUsage(event, sourceApi); - yield frame; - return; - } - yield frame; - } - })(); - completion = await writeSSEFrames(stream, frames, { - keepAlive: { frame: sseCommentFrame('keepalive') }, - downstreamAbortController: ctx.downstreamAbortController, - }); - } catch (error) { - streamError = error; - } finally { - const failed = streamError !== undefined || completion === 'error' || !terminalEventSeen; - if (failed) ctx.dump?.failed(streamError ?? `${sourceApi} stream ended with completion=${completion}`); - else ctx.dump?.success(identity, measurement.dumpTokenUsage); - settleUsageMeasurement(ctx, performance, identity, measurement, failed); - } - }); -}; - -export const respondOpenAIAudioTranscription = async (context: PassthroughResponseStrategyContext): Promise => { - const { ctx, response, performance, identity } = context; - if (!response.ok) { - settleUsageMeasurement(ctx, performance, identity, requestOnlyUsageMeasurement(), true); - ctx.dump?.error('upstream', identity.upstream); - return forwardUpstreamResponse(response, { defaultContentType: null }); - } - return isEventStreamMediaType(response.headers.get('content-type')) - ? respondStreaming(context) - : await respondNonStreaming(context); -}; diff --git a/packages/gateway/src/data-plane/openai-audio/target.ts b/packages/gateway/src/data-plane/openai-audio/target.ts new file mode 100644 index 0000000000..820b6d004a --- /dev/null +++ b/packages/gateway/src/data-plane/openai-audio/target.ts @@ -0,0 +1,24 @@ +import type { Failure } from '../pipeline/facts.ts'; +import type { ModelCandidate } from '@floway-dev/provider'; + +/** Nothing about this request narrows a candidate further. A transcription's parameters are + * form fields the upstream reads for itself, and no endpoint metadata says which renderings + * a model can write — so exposing the endpoint is the whole of the test. */ +export const narrowing = { + kind: 'transcription' as const, + reject: (candidate: ModelCandidate): string | null => + candidate.model.endpoints.openaiAudioTranscriptions === undefined + ? 'the upstream does not expose an OpenAI Audio Transcriptions endpoint' + : null, + unsupported: (model: string) => `Model ${model} does not support the /audio/transcriptions endpoint.`, + refuse: (status: number, message: string) => ({ + 'response.openaiAudioTranscription.canonical': { status, message } as Failure, + 'response.openaiAudioTranscription.mediaType': null, + 'response.openaiAudioTranscription.streamedOutcome': null, + }), + refuses: [ + 'response.openaiAudioTranscription.canonical', + 'response.openaiAudioTranscription.mediaType', + 'response.openaiAudioTranscription.streamedOutcome', + ] as const, +}; diff --git a/packages/gateway/src/data-plane/openai-audio/usage.ts b/packages/gateway/src/data-plane/openai-audio/usage.ts deleted file mode 100644 index 31dbc13610..0000000000 --- a/packages/gateway/src/data-plane/openai-audio/usage.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { UsageQuantities } from '../../repo/types.ts'; -import { requestOnlyUsageMeasurement, tokenUsage, type UsageMeasurement } from '../shared/telemetry/usage.ts'; -import { parseDecimalString } from '@floway-dev/protocols/common'; - -// OpenAI transcription responses discriminate usage by `type`. Token-based -// models split input_token_details into text and audio metrics; without that -// optional split, the aggregate stays on the general input metric. Duration- -// based usage exposes seconds, while Whisper verbose JSON reports the same -// quantity as a top-level `duration`. Unknown breakdowns record the request -// only, while malformed fields under a known discriminator remain observable. -// https://github.com/openai/openai-openapi/blob/db3e53198a66732cfe161339ea63bf36fc0137ad/openapi.yaml#L36378-L36562 -const audioDurationMeasurement = (seconds: unknown, label: string): UsageMeasurement => { - if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) { - throw new Error(`Audio transcription ${label} must be a finite non-negative number`); - } - return { - quantities: { input_audio_seconds: parseDecimalString(String(seconds)) }, - pricingFacts: {}, - dumpTokenUsage: null, - }; -}; - -export const openaiAudioTranscriptionUsageMeasurement = (body: unknown): UsageMeasurement => { - if (!body || typeof body !== 'object') return requestOnlyUsageMeasurement(); - if (!Object.hasOwn(body, 'usage')) { - if (!Object.hasOwn(body, 'duration')) return requestOnlyUsageMeasurement(); - return audioDurationMeasurement((body as { duration: unknown }).duration, 'duration'); - } - const usage = (body as { usage: unknown }).usage; - if (!usage || typeof usage !== 'object' || Array.isArray(usage)) { - throw new Error('Audio transcription usage must be an object'); - } - const metric = usage as { type?: unknown; seconds?: unknown; input_tokens?: unknown; input_token_details?: unknown; output_tokens?: unknown; total_tokens?: unknown }; - - if (metric.type === 'duration') { - return audioDurationMeasurement(metric.seconds, 'duration usage.seconds'); - } - - if (metric.type !== 'tokens') return requestOnlyUsageMeasurement(); - for (const [field, value] of [ - ['input_tokens', metric.input_tokens], - ['output_tokens', metric.output_tokens], - ['total_tokens', metric.total_tokens], - ] as const) { - if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { - throw new Error(`Audio transcription token usage.${field} must be a non-negative safe integer`); - } - } - const inputTokens = metric.input_tokens as number; - const outputTokens = metric.output_tokens as number; - const totalTokens = metric.total_tokens as number; - if (totalTokens !== inputTokens + outputTokens) { - throw new Error('Audio transcription token usage.total_tokens must equal input_tokens plus output_tokens'); - } - - let inputQuantities: UsageQuantities = { input_tokens: parseDecimalString(String(inputTokens)) }; - if (metric.input_token_details !== undefined) { - if (!metric.input_token_details || typeof metric.input_token_details !== 'object' || Array.isArray(metric.input_token_details)) { - throw new Error('Audio transcription token usage.input_token_details must be an object'); - } - const details = metric.input_token_details as { text_tokens?: unknown; audio_tokens?: unknown }; - for (const [field, value] of [ - ['text_tokens', details.text_tokens], - ['audio_tokens', details.audio_tokens], - ] as const) { - if (value !== undefined && (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0)) { - throw new Error(`Audio transcription token usage.input_token_details.${field} must be a non-negative safe integer`); - } - } - const textTokens = details.text_tokens as number | undefined; - const audioTokens = details.audio_tokens as number | undefined; - if ((textTokens ?? 0) + (audioTokens ?? 0) > inputTokens) { - throw new Error('Audio transcription token usage.input_token_details must not exceed input_tokens'); - } - inputQuantities = { - input_tokens: parseDecimalString(String(inputTokens - (audioTokens ?? 0))), - ...(audioTokens === undefined ? {} : { input_audio_tokens: parseDecimalString(String(audioTokens)) }), - }; - } - return { - quantities: { - ...inputQuantities, - output_tokens: parseDecimalString(String(outputTokens)), - }, - pricingFacts: { inputTokens }, - dumpTokenUsage: tokenUsage({ input: inputTokens, output: outputTokens }), - }; -}; - -export const measureOpenAIAudioTranscriptionUsage = (value: unknown, sourceApi: string): UsageMeasurement => { - try { - return openaiAudioTranscriptionUsageMeasurement(value); - } catch (error) { - console.warn( - `audio-transcription: invalid usage in 2xx upstream response for ${sourceApi}; usage row will be request-only`, - error instanceof Error ? error.message : String(error), - ); - return requestOnlyUsageMeasurement(); - } -}; diff --git a/packages/gateway/src/data-plane/openai-completions/call-upstream.ts b/packages/gateway/src/data-plane/openai-completions/call-upstream.ts new file mode 100644 index 0000000000..467ff78c6f --- /dev/null +++ b/packages/gateway/src/data-plane/openai-completions/call-upstream.ts @@ -0,0 +1,246 @@ +import type { Fields, OpenAICompletionsFacts, OpenAICompletionsFrames } from './facts.ts'; +import { tokenUsageFromOpenAICompletionsUsage } from './usage.ts'; +import type { RunDump } from '../../dump/run-sink.ts'; +import { isFailure, type BillableEntity, type Failure } from '../pipeline/facts.ts'; +import { providerEntry } from '../pipeline/provider-entry.ts'; +import { providerUsage } from '../pipeline/provider-usage.ts'; +import type { StreamOutcome } from '../pipeline/serve.ts'; +import type { GatewayServices } from '../pipeline/services.ts'; +import { dialFailure, spentBody, retainReader } from '../pipeline/upstream-body.ts'; +import { upstreamPerformanceContext, telemetryModelIdentity } from '../shared/telemetry/attribution.ts'; +import { tokenUsageMeasurement } from '../shared/telemetry/usage.ts'; +import { recordStream } from '@floway-dev/dump'; +import { exchangeResponse } from '@floway-dev/http/pipeline'; +import { defineStage, move, setRelease, defer, type Owned, type Deferred } from '@floway-dev/pipeline'; +import { isOpenAIUsageOnlyEventShape, type ProtocolFrame } from '@floway-dev/protocols/common'; +import { parseOpenAICompletionsStream, parseOpenAICompletionsResult, type OpenAICompletionsResult, type OpenAICompletionsStreamEvent } from '@floway-dev/protocols/openai-completions'; +import { providerModelOf, type ProviderRequest, type ProviderOperationPayloads, type ProviderResponse, type TelemetryModelIdentity, type ModelCandidate } from '@floway-dev/provider'; + +/** + * A stream as a value the record can hold: a wrapper around the generator rather than the + * generator itself, which is what says where the resource is. There is exactly one resource in + * an OpenAI Completions run — the upstream's body, claimed with `own()` — and the wrapper keeps + * a frame view from reading as another. + * + * What comes back is single-shot on purpose: a stream that has been read is a stream that is + * over, and a second reader learns that rather than being lied to. + */ + +/** + * The ending. It dials, reads what came back on the shape the request asked for, and + * provides the answer, the raw HTTP response beneath it, and what the call is billable for. + * A failure is a value: a 429 here is what an earlier stage fails over, and so is a 200 whose + * body this protocol cannot read, because the next candidate's path and flags may differ. + */ +export const callOpenAICompletionsUpstream = defineStage< + Fields<'ingress.openaiCompletions.wantsStream' | 'request.openaiCompletions.payload' | 'route.attempt' | 'ingress.http.headers'>, + ProviderRequest, + ProviderResponse, + Fields<'response.openaiCompletions.payload' | 'response.openaiCompletions.streamedUsage' | 'response.usage.billable' + | 'response.http.status' | 'response.http.headers' | 'response.http.body'>, + GatewayServices +>({ + name: 'callOpenAICompletionsUpstream', + into: { + request: { needs: ['ingress.openaiCompletions.wantsStream', 'request.openaiCompletions.payload', 'route.attempt', 'ingress.http.headers'], consumes: [], provides: ['request.provider.model', 'request.provider.payload', 'request.http.callId', 'request.http.headers'] }, + response: { needs: ['response.http.exchange', 'response.provider.modelKey', 'response.provider.called', 'response.provider.previousCalls', 'response.http.body'], consumes: ['response.http.exchange', 'response.provider.modelKey', 'response.provider.called', 'response.provider.previousCalls'], provides: ['response.openaiCompletions.payload', 'response.openaiCompletions.streamedUsage', 'response.usage.billable', 'response.http.status', 'response.http.headers', 'response.http.body'] }, + }, + execute: async (facts, next, use) => { + const candidate = use.resolveAttempt(facts['route.attempt']); + // The provider re-stamps whatever id it resolved upstream, so the id the client + // addressed does not travel with the body. + const { model: _addressed, ...body } = facts['request.openaiCompletions.payload']; + // Attribution is set before the dial, so an attempt that never completes still names the + // candidate it was made against rather than the one tried before it. + use.gateway.attempt.telemetry = upstreamPerformanceContext(use.gateway, candidate, 'text_completion'); + + const pipeline = candidate.provider.pipelines.openaiCompletions; + if (pipeline === undefined) throw new Error(`Provider ${candidate.provider.kind} has no openaiCompletions pipeline`); + const back = await next(providerEntry(facts, candidate, body), pipeline); + const { 'response.http.exchange': exchange, 'response.provider.modelKey': modelKey, 'response.provider.called': wasCalled, 'response.provider.previousCalls': _previousCalls, ...rest } = back; + if (exchange.type === 'transportFailure') { + return move({ + ...rest, + 'response.openaiCompletions.payload': dialFailure(exchange.error), + 'response.openaiCompletions.streamedUsage': null, + 'response.usage.billable': providerUsage(candidate, back, []), + 'response.http.status': 502, + 'response.http.headers': [], + 'response.http.body': spentBody(null), + }); + } + const result = { response: exchangeResponse(exchange), modelKey }; + + if (!result.response.ok) await use.log.warn('upstream refused', { status: result.response.status }); + + const answer = await readUpstream( + result.response, + facts['ingress.openaiCompletions.wantsStream'], + telemetryModelIdentity(candidate, result.modelKey), + candidate, + use.gateway.abortSignal, + use.gateway.dump, + exchange.body, + wasCalled, + ); + return move({ + ...rest, + 'response.openaiCompletions.payload': answer.payload, + 'response.openaiCompletions.streamedUsage': answer.streamedUsage === null ? null : defer(answer.streamedUsage.then(outcome => ({ ...outcome, billable: providerUsage(candidate, back, outcome.billable) }))), + 'response.usage.billable': providerUsage(candidate, back, answer.billable), + 'response.http.status': result.response.status, + 'response.http.headers': [...result.response.headers], + 'response.http.body': answer.body, + }); + }, +}); + +interface UpstreamAnswer { + readonly payload: OpenAICompletionsFacts['response.openaiCompletions.payload']; + readonly streamedUsage: OpenAICompletionsFacts['response.openaiCompletions.streamedUsage']; + readonly billable: readonly BillableEntity[]; + /** Every arm hands one up, this stage's own reading included: the record holds a body as a + * stream, and `failover` releases the losing attempts' by consuming that key. */ + readonly body: (ReadableStream & Owned) | null; +} + +/** What the upstream said, on the shape the request asked for. Which of the four this is is + * read from the response, never declared: a stream, a value and a failure sit at one key. */ +const readUpstream = async ( + response: Response, + wantsStream: boolean, + identity: TelemetryModelIdentity, + candidate: ModelCandidate, + signal: AbortSignal | undefined, + dump: RunDump | null, + ownedBody: (ReadableStream & Owned) | null, + wasCalled: boolean, +): Promise => { + // An upstream was called and reported nothing — which is what every arm but a read usage + // block leaves standing, and is a different statement from reporting zero. + const called: readonly BillableEntity[] = wasCalled ? [{ identity, quantities: {} }] : []; + + if (!response.ok) { + // An upstream error body is JSON like any other body. Reading it here is also what + // leaves a losing attempt with nothing open behind it. + spentBody(ownedBody); + return { payload: await refusal(response), streamedUsage: null, billable: called, body: spentBody(ownedBody) }; + } + + if (!wantsStream) { + spentBody(ownedBody); + const read = await readResult(response); + return { + payload: isFailure(read) ? read : read.value, + streamedUsage: null, + billable: isFailure(read) || !wasCalled ? called : [{ identity, ...billed(read.value.usage, read.value.service_tier, candidate) }], + body: spentBody(ownedBody), + }; + } + + if (response.body === null) { + return { + payload: { status: 502, message: 'Upstream returned a streaming response with no body.' }, + streamedUsage: null, + billable: called, + body: spentBody(null), + }; + } + + // Usage is observed here, closest to the upstream and on the protocol it spoke, by folding + // the frames as they pass — so the reading costs one pass and the client's own stream is + // what drives it. What it finds arrives with the last frame, long after this stage has + // handed up, which is why the entity above carries no quantities. + const metered = meterFrames(parseOpenAICompletionsStream(response.body, { signal }), identity, candidate); + setRelease(ownedBody!, async () => { for await (const _frame of metered.frames) { /* drain */ } }); + return { + payload: recordStream(metered.frames, dump), + streamedUsage: metered.outcome, + billable: called, + // Releasing this body is draining those frames: they are one reader over one connection, + // and a second reader is not something a `ReadableStream` allows. + body: ownedBody, + }; +}; + +const refusal = async (response: Response): Promise => { + const text = await response.text(); + try { + return { status: response.status, message: text, body: JSON.parse(text) as unknown }; + } catch { + // A refusal that is not JSON is still a refusal, and its text is what the client is + // told; there is simply no parsed body for a dump reader to open. + return { status: response.status, message: text }; + } +}; + +/** A body that answered 200 and cannot be read as this protocol is an attempt that failed, + * because the gateway serializes what it sends from the value it parsed and has nothing to + * serialize. The next candidate may well answer in the protocol it advertised. */ +const readResult = async (response: Response): Promise<{ readonly value: OpenAICompletionsResult } | Failure> => { + try { + return { value: parseOpenAICompletionsResult(await response.json()) }; + } catch (error) { + return { status: 502, message: `Upstream answered ${response.status} with a body this endpoint cannot read: ${error instanceof Error ? error.message : String(error)}` }; + } +}; + +interface MeteredFrames { + readonly frames: OpenAICompletionsFrames; + readonly outcome: Deferred; +} + +const meterFrames = ( + source: AsyncIterable>, + identity: TelemetryModelIdentity, + candidate: ModelCandidate, +): MeteredFrames => { + const settlement = Promise.withResolvers(); + // Declared as this run's own unfinished work, so the runner waits for it at teardown where + // it can see it rather than the reading being started and forgotten. + const outcome = defer(settlement.promise); + // Running out without the terminal frame is what "it did not finish" means, and it is known + // at the same moment the usage is. + let sawTerminal = false; + const frames = retainReader((async function* () { + let usage: unknown; + let tier: string | null | undefined; + try { + for await (const frame of source) { + if (frame.type === 'event') { + // `service_tier` can ride on any chunk's root while the totals only land on the + // usage-only one, so the two are tracked apart and settled together. + const root = frame.event as { service_tier?: string | null; usage?: unknown }; + if (root.service_tier !== undefined) tier = root.service_tier; + if (isOpenAIUsageOnlyEventShape(frame.event)) usage = root.usage; + } + // The transport's own terminator is this protocol's end-of-turn. + if (frame.type === 'done') sawTerminal = true; + yield frame; + } + } finally { + // Reached however the frames ended — the terminal chunk, a client that stopped + // reading, or a broken upstream — because tokens the upstream already metered are + // billable whatever happened to the downstream half. + try { + settlement.resolve({ billable: [{ identity, ...billed(usage, tier, candidate) }], failed: !sawTerminal }); + } catch (error) { + settlement.reject(error); + throw error; + } + } + })()); + return { frames, outcome }; +}; + +const billed = (usage: unknown, tier: string | null | undefined, candidate: ModelCandidate): Pick => { + const model = providerModelOf(candidate); + const tokens = tokenUsageFromOpenAICompletionsUsage( + usage, + tier, + model.enabledFlags.has('usage-exclusive-cached-tokens'), + `${candidate.provider.upstreamId}/${model.id}`, + ); + const measurement = tokenUsageMeasurement(tokens); + return { quantities: measurement.quantities, pricingFacts: measurement.pricingFacts }; +}; diff --git a/packages/gateway/src/data-plane/openai-completions/emit.ts b/packages/gateway/src/data-plane/openai-completions/emit.ts new file mode 100644 index 0000000000..224625d53e --- /dev/null +++ b/packages/gateway/src/data-plane/openai-completions/emit.ts @@ -0,0 +1,93 @@ +import type { OpenAICompletionsFacts, OpenAICompletionsFrames, Fields } from './facts.ts'; +import type { RunDump } from '../../dump/run-sink.ts'; +import { isFailure } from '../pipeline/facts.ts'; +import type { GatewayServices } from '../pipeline/services.ts'; +import { isForwardableUpstreamHeader } from '../shared/upstream-response.ts'; +import { recordStream, streamReferenceOf } from '@floway-dev/dump'; +import { defineStage, move } from '@floway-dev/pipeline'; +import { renderErrorEnvelope, isOpenAIUsageOnlyEventShape, type SseFrame } from '@floway-dev/protocols/common'; +import { openaiCompletionsProtocolFrameToSSEFrame } from '@floway-dev/protocols/openai-completions'; + +const isFrames = (answer: OpenAICompletionsFacts['response.openaiCompletions.payload']): answer is OpenAICompletionsFrames => + Symbol.asyncIterator in answer; + +/** + * The outermost edge, and the only place where what the client asked for and what the + * upstream is asked for differ: billing needs the usage chunk on every stream, so it is + * turned on going down and taken back out coming up unless the client asked to see it. + * + * Rendering the answer is the other half. SSE framing is produced here and nowhere else — + * below this stage a stream carries protocol frames, so the same assembly would serve + * another transport by rendering differently at this one point. + */ +export const emitOpenAICompletions = defineStage< + Fields<'ingress.openaiCompletions.wantsStream' | 'ingress.openaiCompletions.wantsUsageChunk' | 'request.openaiCompletions.payload'>, + Fields<'ingress.openaiCompletions.wantsStream' | 'ingress.openaiCompletions.wantsUsageChunk' | 'request.openaiCompletions.payload'>, + Fields<'ingress.openaiCompletions.wantsUsageChunk' | 'response.openaiCompletions.payload' | 'response.http.status' | 'response.http.headers'>, + Fields<'response.openaiCompletions.rendered' | 'response.http.status' | 'response.http.headers'>, + GatewayServices +>({ + name: 'emitOpenAICompletions', + through: { + request: { + needs: ['ingress.openaiCompletions.wantsStream', 'ingress.openaiCompletions.wantsUsageChunk', 'request.openaiCompletions.payload'], + consumes: [], + provides: ['request.openaiCompletions.payload'], + }, + response: { + needs: ['response.openaiCompletions.payload', 'response.http.headers', 'response.http.status'], + consumes: ['response.openaiCompletions.payload', 'response.http.headers'], + provides: ['response.openaiCompletions.rendered', 'response.http.status', 'response.http.headers'], + }, + }, + execute: async (facts, next, use) => { + const asked = facts['request.openaiCompletions.payload']; + const back = await next({ + ...facts, + 'request.openaiCompletions.payload': move(facts['ingress.openaiCompletions.wantsStream'] + ? { ...asked, stream_options: { ...asked.stream_options, include_usage: true } } + : asked), + }); + + const { 'response.openaiCompletions.payload': answer, 'response.http.headers': headers, ...rest } = back; + // Vendor traces and quota state stay visible; what an intermediary must strip, and what + // would misdescribe a body this gateway serialized itself, does not. A filter that removed + // nothing hands the same array on, so the record shows no change where none happened. + const forwardable = headers.filter(([name]) => isForwardableUpstreamHeader(name)); + return { + ...rest, + 'response.http.headers': forwardable.length === headers.length ? headers : move(forwardable), + 'response.http.status': isFailure(answer) ? answer.status : back['response.http.status'], + 'response.openaiCompletions.rendered': move(rendered(answer, back['ingress.openaiCompletions.wantsUsageChunk'], use.gateway.dump)), + }; + }, +}); + +const rendered = ( + answer: OpenAICompletionsFacts['response.openaiCompletions.payload'], + wantsUsageChunk: boolean, + dump: RunDump | null, +): OpenAICompletionsFacts['response.openaiCompletions.rendered'] => + isFailure(answer) ? renderErrorEnvelope(answer.message, answer.body) + : isFrames(answer) ? renderSSE(wantsUsageChunk ? answer : recordStream(withoutUsage(answer), dump)) + : answer; + +const renderSSE = (frames: OpenAICompletionsFrames): AsyncIterable => ({ + // The frames the client reads are a reframing of the ones the record holds, so this key + // points at that same stream rather than at nothing. + ...streamReferenceOf(frames), + [Symbol.asyncIterator]: () => (async function* () { + for await (const frame of frames) { + yield openaiCompletionsProtocolFrameToSSEFrame(frame); + } + })(), +}); + +const withoutUsage = (frames: OpenAICompletionsFrames): OpenAICompletionsFrames => ({ + async *[Symbol.asyncIterator]() { + for await (const frame of frames) { + if (frame.type === 'event' && isOpenAIUsageOnlyEventShape(frame.event)) continue; + yield frame; + } + }, +}); diff --git a/packages/gateway/src/data-plane/openai-completions/facts.ts b/packages/gateway/src/data-plane/openai-completions/facts.ts new file mode 100644 index 0000000000..5c4e7c9b72 --- /dev/null +++ b/packages/gateway/src/data-plane/openai-completions/facts.ts @@ -0,0 +1,35 @@ +import type { GatewayFacts, Failure } from '../pipeline/facts.ts'; +import type { StreamOutcome } from '../pipeline/serve.ts'; +import type { Deferred } from '@floway-dev/pipeline'; +import type { ProtocolFrame, SseFrame } from '@floway-dev/protocols/common'; +import type { OpenAICompletionsStreamEvent, OpenAICompletionsPayload, OpenAICompletionsResult } from '@floway-dev/protocols/openai-completions'; + +/** The answer while it is still the upstream's, one frame at a time. */ +export type OpenAICompletionsFrames = AsyncIterable>; + +/** OpenAI Completions' own keys, extending the shared space by intersection. */ +export interface OpenAICompletionsFacts extends GatewayFacts { + /** What the client asked for, which is not what the upstream is asked for: the gateway + * meters every stream and so always turns the usage chunk on. These stay put for the same + * reason every `ingress.*` key does — they describe the request that arrived, and the + * answer is rendered back into it. */ + 'ingress.openaiCompletions.wantsStream': boolean; + 'ingress.openaiCompletions.wantsUsageChunk': boolean; + 'request.openaiCompletions.payload': OpenAICompletionsPayload; + /** The answer, whichever kind it turned out to be. A stream, a value and a failure sit at + * one key: telling them apart is reading a value, and each stage does that where it needs + * to. */ + 'response.openaiCompletions.payload': OpenAICompletionsResult | OpenAICompletionsFrames | Failure; + /** What the upstream will have reported by the time the frames run out, and `null` on + * every path that does not stream. A stream's usage arrives with its last chunk, which is + * after this run has answered — so the numbers cannot be in `response.usage.billable`, + * which says what had been reported when the ending stage handed up: the entity, and no + * quantities. Settling billing from this is the prologue's job, after the drain. */ + 'response.openaiCompletions.streamedUsage': Deferred | null; + /** What the client is actually sent, in its own protocol — a JSON body, or the SSE frames + * of a stream. The edge provides it, so a dump shows what the client received rather than + * the gateway's own reading of it. */ + 'response.openaiCompletions.rendered': Record | AsyncIterable; +} + +export type Fields = { [P in K]: OpenAICompletionsFacts[P] }; diff --git a/packages/gateway/src/data-plane/openai-completions/http.ts b/packages/gateway/src/data-plane/openai-completions/http.ts index 7b74fbe483..3997b90b36 100644 --- a/packages/gateway/src/data-plane/openai-completions/http.ts +++ b/packages/gateway/src/data-plane/openai-completions/http.ts @@ -1,96 +1,54 @@ -// POST /v1/completions and /completions — OpenAI Completions passthrough. -// The endpoint sits outside the chat source/target executor: -// no protocol translation, no interceptor chain, no cross-protocol -// traversal. The request body is forwarded to the chosen provider's -// /completions verbatim; the response (single-shot JSON or streaming SSE -// depending on the client's `stream` flag) flows back through the shared -// passthroughServe scaffold. +// POST /v1/completions and /completions, served through the pipeline. +// +// The handler is a prologue and an epilogue around `openaiCompletionsServePipeline`: read +// what the client sent, hand it over, and turn what the run answered with into a response. The +// one thing it decides for itself is whether the request streams, because that is written in +// the body and the run has to be opened knowing it. import type { Context } from 'hono'; -import { tokenUsageFromOpenAICompletionsUsage } from './usage.ts'; -import type { TokenUsage } from '../../repo/types.ts'; -import { backgroundSchedulerFromContext } from '../../runtime/background.ts'; -import { createGatewayCtxFromHono, finalizeGatewayResponse } from '../shared/gateway-ctx.ts'; -import { prepareJsonModelRequest } from '../shared/passthrough-request.ts'; -import { passthroughApiError, passthroughServe } from '../shared/passthrough-serve.ts'; -import { readRequestBody, takeRequestBody } from '../shared/request-body.ts'; -import { isOpenAIUsageOnlyEventShape, type ProtocolFrame } from '@floway-dev/protocols/common'; -import type { ProviderModel } from '@floway-dev/provider'; +import { openaiCompletionsServePipeline } from './pipeline.ts'; +import { isFrames, openPrologue, readIngress, serveThrough } from '../pipeline/serve.ts'; +import { finalizeGatewayResponse } from '../shared/gateway-ctx.ts'; +import { prepareJsonModelRequest } from '../shared/json-model-request.ts'; +import { move } from '@floway-dev/pipeline'; export const openaiCompletions = async (c: Context): Promise => { - const requestBody = await readRequestBody(c); - const request = prepareJsonModelRequest(requestBody.bytes, 'OpenAI Completions'); - // `stream` decides the response shape, so the gateway context has to learn - // it before the invalid branch — which has no body to read it from. - const wantsStream = request.type === 'ok' && request.body.stream === true; - const ctx = createGatewayCtxFromHono(c, { - wantsStream, - requestBody: takeRequestBody(requestBody), - backgroundScheduler: backgroundSchedulerFromContext(c), - }); + const ingress = await readIngress(c); + const request = prepareJsonModelRequest(ingress.body.bytes, 'OpenAI Completions'); if (request.type === 'invalid') { - ctx.dump?.error('gateway'); - return finalizeGatewayResponse(ctx, passthroughApiError(c, request.message, 400)); + // A request the gateway could not read never reaches a pipeline: there is no model to + // resolve and no attempt to make, so there is nothing for a run to record. + const refused = openPrologue(c, ingress, { wantsStream: false }); + refused.gateway.dump?.error('gateway'); + return finalizeGatewayResponse( + refused.gateway, + Response.json({ error: { message: request.message, type: 'api_error' } }, { status: 400 }), + ); } - ctx.dump?.requestedModel(request.model); + const wantsStream = request.body.stream === true; const streamOptions = request.body.stream_options as { include_usage?: unknown } | null | undefined; - const clientWantsUsageChunk = streamOptions?.include_usage === true; - // Strip the inbound model; the provider re-stamps the upstream-resolved - // model id. For streaming requests we force `stream_options.include_usage` - // on so billing always sees the usage chunk — sibling keys on - // stream_options (if any) ride through unchanged. - const { model: _model, ...upstreamBodyBase } = request.body; - const upstreamBody = wantsStream - ? { ...upstreamBodyBase, stream_options: { ...(streamOptions ?? {}), include_usage: true } } - : upstreamBodyBase; + const prologue = openPrologue(c, ingress, { wantsStream, model: request.model }); - // Streaming closure: track the usage block (only on the usage-only - // chunk per OpenAI spec) and service_tier independently — service_tier - // can ride on any event root, so settling them together at the end - // lets the tier override land regardless of which chunk carried it. - let streamingUsageBlock: unknown = null; - let streamingServiceTier: string | null | undefined; - // The scaffold picks the upstream after these closures are built, and this - // endpoint has no interceptor chain to normalize usage on the way in, so - // the serving model is staked here for the billing read to consult. - let serving: { model: ProviderModel; upstreamId: string } | undefined; - const declaredExclusive = (): boolean => serving?.model.enabledFlags.has('usage-exclusive-cached-tokens') === true; - const servingIdentity = (): string => serving === undefined ? 'unresolved upstream' : `${serving.upstreamId}/${serving.model.id}`; - const transformFrame = (frame: ProtocolFrame): ProtocolFrame | null => { - if (frame.type !== 'event') return frame; - const eventRoot = frame.event as { service_tier?: string | null; usage?: unknown }; - if (eventRoot.service_tier !== undefined) streamingServiceTier = eventRoot.service_tier; - if (!isOpenAIUsageOnlyEventShape(frame.event)) return frame; - streamingUsageBlock = eventRoot.usage; - return clientWantsUsageChunk ? frame : null; - }; - const settleUsage = (): TokenUsage | null => - streamingUsageBlock === null ? null : tokenUsageFromOpenAICompletionsUsage(streamingUsageBlock, streamingServiceTier, declaredExclusive(), servingIdentity()); - - const response = await passthroughServe({ + return await serveThrough( c, - ctx, - sourceApi: '/completions', - operation: 'text_completion', - model: request.model, - kind: 'chat', - modelServesEndpoint: model => model.endpoints.openaiCompletions !== undefined, - call: (provider, model, opts) => { - serving = { model, upstreamId: provider.upstreamId }; - return provider.instance.callOpenAICompletions(model, upstreamBody, ctx.abortSignal, opts); + prologue, + openaiCompletionsServePipeline, + move({ + 'ingress.http.headers': prologue.headers, + 'ingress.openaiCompletions.wantsStream': wantsStream, + // Whether the client asked to *see* the usage chunk. The edge turns it on upstream + // either way so billing always gets one, and drops it here when it was not asked for. + 'ingress.openaiCompletions.wantsUsageChunk': streamOptions?.include_usage === true, + 'request.openaiCompletions.payload': request.body, + 'serve.model': request.model, + }) as never, + facts => { + const rendered = facts['response.openaiCompletions.rendered']; + return isFrames(rendered) + ? { frames: rendered } + : { body: facts['response.http.jsonBody']!, contentType: 'application/json' }; }, - response: wantsStream - ? { format: 'sse', transformFrame, settleUsage } - : { - format: 'json', - extractBilling: (body: unknown) => { - if (!body || typeof body !== 'object') return null; - const { usage, service_tier: tier } = body as { usage?: unknown; service_tier?: string | null }; - return tokenUsageFromOpenAICompletionsUsage(usage, tier, declaredExclusive(), servingIdentity()); - }, - }, - }); - return finalizeGatewayResponse(ctx, response); + ); }; diff --git a/packages/gateway/src/data-plane/openai-completions/pipeline.ts b/packages/gateway/src/data-plane/openai-completions/pipeline.ts new file mode 100644 index 0000000000..f28f10208e --- /dev/null +++ b/packages/gateway/src/data-plane/openai-completions/pipeline.ts @@ -0,0 +1,26 @@ +import { callOpenAICompletionsUpstream } from './call-upstream.ts'; +import { emitOpenAICompletions } from './emit.ts'; +import type { Fields } from './facts.ts'; +import { narrowing } from './target.ts'; +import { isFailure } from '../pipeline/facts.ts'; +import { failover } from '../pipeline/failover.ts'; +import { resolveCandidates } from '../pipeline/resolve-candidates.ts'; +import { serializeClientJson } from '../pipeline/serialize-client-json.ts'; +import { writeSettlement } from '../pipeline/settlement.ts'; +import { compose, type Pipeline } from '@floway-dev/pipeline'; + +export const openaiCompletionsServePipeline: Pipeline< + Fields<'ingress.http.headers' | 'ingress.openaiCompletions.wantsStream' | 'ingress.openaiCompletions.wantsUsageChunk' | 'request.openaiCompletions.payload' | 'serve.model'>, + Fields<'response.http.jsonBody' | 'response.openaiCompletions.rendered' | 'response.openaiCompletions.streamedUsage' | 'response.http.status' | 'response.http.headers' | 'response.usage.billable'> +> = compose('openaiCompletionsServe', [ + writeSettlement(handedUp => Number(handedUp['response.http.status']) >= 400, 'response.openaiCompletions.streamedUsage'), + serializeClientJson('response.openaiCompletions.rendered'), + emitOpenAICompletions, + resolveCandidates(narrowing), + failover({ + failed: handedUp => isFailure((handedUp as { 'response.openaiCompletions.payload'?: unknown })['response.openaiCompletions.payload']), + owns: ['response.http.body'], + pendingUsage: 'response.openaiCompletions.streamedUsage', + }), + callOpenAICompletionsUpstream, +]); diff --git a/packages/gateway/src/data-plane/openai-completions/target.ts b/packages/gateway/src/data-plane/openai-completions/target.ts new file mode 100644 index 0000000000..d12b85364e --- /dev/null +++ b/packages/gateway/src/data-plane/openai-completions/target.ts @@ -0,0 +1,17 @@ +import type { Fields } from './facts.ts'; +import type { ModelCandidate } from '@floway-dev/provider'; + +/** A candidate that cannot serve this endpoint is not a candidate. The resolver's own filter + * is by kind, and a text-completion model is a chat-kind model, so what is left to say is + * whether the upstream exposes the endpoint at all. */ +export const narrowing = { + kind: 'chat' as const, + reject: (candidate: ModelCandidate): string | null => + candidate.model.endpoints.openaiCompletions === undefined ? 'the upstream does not expose an OpenAI Completions endpoint' : null, + unsupported: (model: string) => `Model ${model} does not support the /completions endpoint.`, + refuse: (status: number, message: string): Fields<'response.openaiCompletions.payload' | 'response.openaiCompletions.streamedUsage'> => ({ + 'response.openaiCompletions.payload': { status, message }, + 'response.openaiCompletions.streamedUsage': null, + }), + refuses: ['response.openaiCompletions.payload', 'response.openaiCompletions.streamedUsage'] as const, +}; diff --git a/packages/gateway/src/data-plane/openai-completions/usage.ts b/packages/gateway/src/data-plane/openai-completions/usage.ts index f753f8ca47..ab99173838 100644 --- a/packages/gateway/src/data-plane/openai-completions/usage.ts +++ b/packages/gateway/src/data-plane/openai-completions/usage.ts @@ -1,5 +1,5 @@ import type { TokenUsage } from '../../repo/types.ts'; -import { foldsExclusiveCacheTokens, openAICacheTokensFromUsage, tokenUsage } from '../shared/telemetry/usage.ts'; +import { foldsExclusiveCacheTokens, openAICacheTokensFromUsage } from '../shared/telemetry/usage.ts'; import { billableServiceTier, splitInclusiveInputTokens } from '@floway-dev/protocols/common'; // `/v1/completions` shares OpenAI's CompletionUsage schema with @@ -16,11 +16,10 @@ import { billableServiceTier, splitInclusiveInputTokens } from '@floway-dev/prot // non-streaming /v1/completions body (observed null on a Zhipu/GLM // fork); the streaming path was observed to omit the field. // -// This endpoint is a passthrough with no interceptor chain, so the fold the -// chat targets apply to the usage chunk itself happens here instead, on the -// one read that consumes it. `declaredExclusive` carries the serving -// upstream's `usage-exclusive-cached-tokens` flag and `identity` names it in -// whatever `foldsExclusiveCacheTokens` raises. +// This is the one read of a completions usage block, so the fold the chat targets apply to +// the usage chunk itself happens here instead. `declaredExclusive` carries the serving +// upstream's `usage-exclusive-cached-tokens` flag and `identity` names it in whatever +// `foldsExclusiveCacheTokens` raises. export const tokenUsageFromOpenAICompletionsUsage = ( usage: unknown, @@ -44,11 +43,11 @@ export const tokenUsageFromOpenAICompletionsUsage = ( cacheWrite, }, identity); const split = splitInclusiveInputTokens(fold ? promptTokens + cacheRead + cacheWrite : promptTokens, cacheRead, cacheWrite); - return tokenUsage({ + return { input: split.input, input_cache_read: split.cacheRead, input_cache_write: split.cacheWrite, output: completionTokens, tier: billableServiceTier(serviceTier), - }); + }; }; diff --git a/packages/gateway/src/data-plane/openai-embeddings/call-upstream.ts b/packages/gateway/src/data-plane/openai-embeddings/call-upstream.ts new file mode 100644 index 0000000000..9e7c75978c --- /dev/null +++ b/packages/gateway/src/data-plane/openai-embeddings/call-upstream.ts @@ -0,0 +1,99 @@ +import type { Fields } from './facts.ts'; +import type { UsageQuantities } from '../../repo/types.ts'; +import type { Failure } from '../pipeline/facts.ts'; +import { providerEntry } from '../pipeline/provider-entry.ts'; +import { providerUsage } from '../pipeline/provider-usage.ts'; +import type { GatewayServices } from '../pipeline/services.ts'; +import { dialFailure, readUpstreamBody, unreadableBody } from '../pipeline/upstream-body.ts'; +import { upstreamPerformanceContext, telemetryModelIdentity } from '../shared/telemetry/attribution.ts'; +import { exchangeResponse } from '@floway-dev/http/pipeline'; +import { defineStage, move, setRelease } from '@floway-dev/pipeline'; +import { upstreamErrorMessage, parseDecimalString } from '@floway-dev/protocols/common'; +import { serializeOpenAIEmbeddingsRequest, parseOpenAIEmbeddingsResponse, type CanonicalOpenAIEmbeddingsResponse, type CanonicalOpenAIEmbeddingsUsage } from '@floway-dev/protocols/openai-embeddings'; +import { type ProviderRequest, type ProviderResponse, type ProviderOperationPayloads } from '@floway-dev/provider'; + +/** + * The ending. It dials, reads the upstream's body, and provides the canonical answer and + * what the call is billable for. A failure is a value: a 429 here is what an earlier stage + * fails over, and even a 400 can be, because the next candidate's path and flags may differ. + */ +export const callOpenAIEmbeddingsUpstream = defineStage< + Fields<'request.openaiEmbeddings.canonical' | 'route.attempt' | 'ingress.http.headers' | 'serve.model'>, + ProviderRequest, + ProviderResponse, + Fields<'response.openaiEmbeddings.canonical' | 'response.http.status' | 'response.http.headers' | 'response.http.body' | 'response.usage.billable'>, + GatewayServices +>({ + name: 'callOpenAIEmbeddingsUpstream', + into: { + request: { needs: ['request.openaiEmbeddings.canonical', 'route.attempt', 'ingress.http.headers', 'serve.model'], consumes: [], provides: ['request.provider.model', 'request.provider.payload', 'request.http.callId', 'request.http.headers'] }, + response: { needs: ['response.http.exchange', 'response.provider.modelKey', 'response.provider.called'], consumes: ['response.http.exchange', 'response.provider.modelKey', 'response.provider.called', 'response.provider.previousCalls'], provides: ['response.openaiEmbeddings.canonical', 'response.http.status', 'response.http.headers', 'response.http.body', 'response.usage.billable'] }, + }, + execute: async (facts, next, use) => { + const candidate = use.resolveAttempt(facts['route.attempt']); + // Attribution is set before the dial, so an attempt that never completes still names the + // candidate it was made against rather than the one tried before it. + use.gateway.attempt.telemetry = upstreamPerformanceContext(use.gateway, candidate, 'embeddings'); + + const pipeline = candidate.provider.pipelines.openaiEmbeddings; + if (pipeline === undefined) throw new Error(`Provider ${candidate.provider.kind} has no OpenAI Embeddings pipeline`); + const back = await next(providerEntry(facts, candidate, serializeOpenAIEmbeddingsRequest(facts['request.openaiEmbeddings.canonical'])), pipeline); + const { 'response.http.exchange': exchange, 'response.provider.modelKey': modelKey, 'response.provider.called': called, 'response.provider.previousCalls': _previousCalls, ...rest } = back; + if (exchange.type === 'transportFailure') { + return move({ + ...rest, + 'response.openaiEmbeddings.canonical': dialFailure(exchange.error), + 'response.http.status': 502, + 'response.http.headers': [], + 'response.usage.billable': providerUsage(candidate, back, []), + }); + } + const result = { response: exchangeResponse(exchange), modelKey }; + + const identity = telemetryModelIdentity(candidate, result.modelKey); + // What came back, unfiltered: the edge is where a client's view of it is decided. + const headers = [...result.response.headers]; + const body = await readUpstreamBody(result.response).finally(() => { + if (exchange.body !== null) setRelease(exchange.body, async () => {}); + }); + // The upstream was called and reported nothing, which is a different situation from + // reporting zero — so the entity is present with no quantities. + const answered = (canonical: CanonicalOpenAIEmbeddingsResponse | Failure, quantities: UsageQuantities) => move({ + ...rest, + 'response.openaiEmbeddings.canonical': canonical, + 'response.http.status': result.response.status, + 'response.http.headers': headers, + 'response.usage.billable': providerUsage(candidate, back, called ? [{ identity, quantities }] : []), + }); + const reportedNothing: UsageQuantities = {}; + + if (!result.response.ok) { + await use.log.warn('upstream refused', { status: result.response.status }); + return answered({ + status: result.response.status, + message: upstreamErrorMessage(body.json) ?? body.text, + ...('json' in body ? { body: body.json } : {}), + }, reportedNothing); + } + if (!('json' in body)) { + return answered(unreadableBody(result.response, body, 'the OpenAI Embeddings protocol'), reportedNothing); + } + + // Every protocol the gateway carries is one it fully understands: the body is parsed + // here and written again at the edge, in whichever encoding the client can read. + let canonical: CanonicalOpenAIEmbeddingsResponse; + try { + canonical = parseOpenAIEmbeddingsResponse(body.json, facts['serve.model']); + } catch (error) { + await use.log.warn('upstream answered with a body the OpenAI Embeddings protocol cannot read', { error: String(error) }); + return answered(unreadableBody(result.response, body, 'the OpenAI Embeddings protocol'), reportedNothing); + } + return answered(canonical, billed(canonical.usage)); + }, +}); + +// An OpenAI Embeddings call has no output side, so `prompt_tokens` is the whole of what the +// upstream metered and `total_tokens` restates it. An upstream that reported nothing bills +// nothing, and says so by leaving the entity's quantities empty. +const billed = (usage: CanonicalOpenAIEmbeddingsUsage | undefined): UsageQuantities => + usage === undefined ? {} : { input_tokens: parseDecimalString(String(usage.promptTokens)) }; diff --git a/packages/gateway/src/data-plane/openai-embeddings/emit.ts b/packages/gateway/src/data-plane/openai-embeddings/emit.ts new file mode 100644 index 0000000000..1b0ce7a199 --- /dev/null +++ b/packages/gateway/src/data-plane/openai-embeddings/emit.ts @@ -0,0 +1,59 @@ +import type { Fields } from './facts.ts'; +import { isFailure } from '../pipeline/facts.ts'; +import { isForwardableUpstreamHeader } from '../shared/upstream-response.ts'; +import { defineStage, move } from '@floway-dev/pipeline'; +import { renderErrorEnvelope } from '@floway-dev/protocols/common'; +import { renderOpenAIEmbeddingsResponse } from '@floway-dev/protocols/openai-embeddings'; + +/** + * The outermost edge. Writes the vectors in the encoding the client asked for — which is + * why the encoding is an ingress fact and not a request one: it has to survive an upstream + * that answered in the other one. A client on an official OpenAI SDK asked for `base64` + * without its caller choosing to, and will decode as base64 whatever it is handed. + */ +export const emitOpenAIEmbeddings = defineStage< + Fields<'ingress.openaiEmbeddings.encodingFormat'>, + Fields<'ingress.openaiEmbeddings.encodingFormat'>, + Fields<'ingress.openaiEmbeddings.encodingFormat' | 'response.openaiEmbeddings.canonical' | 'response.http.status' | 'response.http.headers'>, + Fields<'response.openaiEmbeddings.rendered' | 'response.http.status' | 'response.http.headers'> +>({ + name: 'emitOpenAIEmbeddings', + through: { + request: { + needs: ['ingress.openaiEmbeddings.encodingFormat'], + consumes: [], + provides: [], + }, + response: { + needs: ['response.openaiEmbeddings.canonical', 'response.http.headers', 'response.http.status'], + consumes: ['response.openaiEmbeddings.canonical', 'response.http.headers'], + provides: ['response.openaiEmbeddings.rendered', 'response.http.status', 'response.http.headers'], + }, + }, + execute: async (facts, next) => { + const back = await next(facts); + const { 'response.openaiEmbeddings.canonical': answer, 'response.http.headers': headers, ...rest } = back; + // Vendor traces and quota state stay visible; what an intermediary must strip, and what + // would misdescribe a body this gateway serialized itself, does not. A filter that removed + // nothing hands the same array on, so the record shows no change where none happened. + const forwardable = headers.filter(([name]) => isForwardableUpstreamHeader(name)); + const forClient = forwardable.length === headers.length ? headers : move(forwardable); + if (isFailure(answer)) { + return { + ...rest, + 'response.http.headers': forClient, + 'response.openaiEmbeddings.rendered': move(renderErrorEnvelope(answer.message, answer.body)), + // The upstream's own status, or the gateway's own when it refused before dialling. + // A client is not owed the upstream's exact bytes; it is owed the truth about what + // happened, and a 429 arriving as a 200 is not that. + 'response.http.status': answer.status, + }; + } + return { + ...rest, + 'response.http.headers': forClient, + 'response.http.status': back['response.http.status'], + 'response.openaiEmbeddings.rendered': move(renderOpenAIEmbeddingsResponse(back['ingress.openaiEmbeddings.encodingFormat'], answer)), + }; + }, +}); diff --git a/packages/gateway/src/data-plane/openai-embeddings/facts.ts b/packages/gateway/src/data-plane/openai-embeddings/facts.ts new file mode 100644 index 0000000000..382dc5b6c3 --- /dev/null +++ b/packages/gateway/src/data-plane/openai-embeddings/facts.ts @@ -0,0 +1,18 @@ +import type { GatewayFacts, Failure } from '../pipeline/facts.ts'; +import type { OpenAIEmbeddingsEncodingFormat, CanonicalOpenAIEmbeddingsRequest, CanonicalOpenAIEmbeddingsResponse } from '@floway-dev/protocols/openai-embeddings'; + +/** OpenAI Embeddings' own keys. They extend the shared space and never merge into it, so a stage + * written against the gateway alone cannot name one. */ +export interface OpenAIEmbeddingsFacts extends GatewayFacts { + /** Which encoding the client is able to read. It belongs to the ingress and stays put: + * the answer is written in it whichever encoding the upstream chose to answer in, and + * the two differ whenever an upstream ignores the field. */ + 'ingress.openaiEmbeddings.encodingFormat': OpenAIEmbeddingsEncodingFormat; + 'request.openaiEmbeddings.canonical': CanonicalOpenAIEmbeddingsRequest; + 'response.openaiEmbeddings.canonical': CanonicalOpenAIEmbeddingsResponse | Failure; + /** What the client is actually sent. The edge provides it, so a dump shows the bytes the + * client received rather than the gateway's canonical form. */ + 'response.openaiEmbeddings.rendered': Record; +} + +export type Fields = { [P in K]: OpenAIEmbeddingsFacts[P] }; diff --git a/packages/gateway/src/data-plane/openai-embeddings/http.ts b/packages/gateway/src/data-plane/openai-embeddings/http.ts index e00e0691a7..883f030639 100644 --- a/packages/gateway/src/data-plane/openai-embeddings/http.ts +++ b/packages/gateway/src/data-plane/openai-embeddings/http.ts @@ -1,38 +1,54 @@ -// POST /v1/embeddings — route embedding requests to the provider that -// declares the requested model and OpenAI Embeddings capability. +// POST /v1/embeddings, served through the pipeline. +// +// The handler is a prologue and an epilogue around `openaiEmbeddingsServePipeline`: parse what +// the client sent, hand it over, and turn what the run answered with into a response. +// Everything between is stages. import type { Context } from 'hono'; -import { tokenUsageFromOpenAIEmbeddingsBody } from './usage.ts'; -import { backgroundSchedulerFromContext } from '../../runtime/background.ts'; -import { createGatewayCtxFromHono, finalizeGatewayResponse } from '../shared/gateway-ctx.ts'; -import { prepareJsonModelRequest } from '../shared/passthrough-request.ts'; -import { passthroughApiError, passthroughServe } from '../shared/passthrough-serve.ts'; -import { readRequestBody, takeRequestBody } from '../shared/request-body.ts'; +import { openaiEmbeddingsServePipeline } from './pipeline.ts'; +import { openPrologue, readIngress, serveThrough } from '../pipeline/serve.ts'; +import { finalizeGatewayResponse } from '../shared/gateway-ctx.ts'; +import { move } from '@floway-dev/pipeline'; +import { parseOpenAIEmbeddingsRequest, type ParsedOpenAIEmbeddingsRequest } from '@floway-dev/protocols/openai-embeddings'; + +// The contract reports a malformed request by throwing; what the client is owed is a 400 +// carrying the reason. +const readRequest = (bytes: Uint8Array): { type: 'ok'; parsed: ParsedOpenAIEmbeddingsRequest } | { type: 'invalid'; message: string } => { + try { + return { type: 'ok', parsed: parseOpenAIEmbeddingsRequest(JSON.parse(new TextDecoder().decode(bytes)) as unknown) }; + } catch (error) { + return { type: 'invalid', message: error instanceof Error ? error.message : String(error) }; + } +}; export const openaiEmbeddings = async (c: Context): Promise => { - const requestBody = await readRequestBody(c); - const request = prepareJsonModelRequest(requestBody.bytes, 'OpenAI Embeddings'); - const ctx = createGatewayCtxFromHono(c, { wantsStream: false, requestBody: takeRequestBody(requestBody), backgroundScheduler: backgroundSchedulerFromContext(c) }); - if (request.type === 'invalid') { - ctx.dump?.error('gateway'); - return finalizeGatewayResponse(ctx, passthroughApiError(c, request.message, 400)); + const ingress = await readIngress(c); + const result = readRequest(ingress.body.bytes); + if (result.type === 'invalid') { + // A request the gateway could not read never reaches a pipeline: there is no model to + // resolve and no attempt to make, so there is nothing for a run to record. + const refused = openPrologue(c, ingress, { wantsStream: false }); + refused.gateway.dump?.error('gateway'); + return finalizeGatewayResponse( + refused.gateway, + Response.json({ error: { message: result.message, type: 'api_error' } }, { status: 400 }), + ); } - ctx.dump?.requestedModel(request.model); - const response = await passthroughServe({ + const { model, request } = result.parsed; + const prologue = openPrologue(c, ingress, { wantsStream: false, model }); + + return await serveThrough( c, - ctx, - sourceApi: '/embeddings', - operation: 'embeddings', - model: request.model, - kind: 'embedding', - modelServesEndpoint: model => model.endpoints.openaiEmbeddings !== undefined, - call: async (provider, model, opts) => { - const { model: _model, ...body } = request.body; - return await provider.instance.callOpenAIEmbeddings(model, body, undefined, opts); - }, - response: { format: 'json', extractBilling: tokenUsageFromOpenAIEmbeddingsBody }, - }); - return finalizeGatewayResponse(ctx, response); + prologue, + openaiEmbeddingsServePipeline, + move({ + 'ingress.http.headers': prologue.headers, + 'ingress.openaiEmbeddings.encodingFormat': request.encodingFormat, + 'request.openaiEmbeddings.canonical': request, + 'serve.model': model, + }) as never, + facts => ({ body: facts['response.http.jsonBody']!, contentType: 'application/json' }), + ); }; diff --git a/packages/gateway/src/data-plane/openai-embeddings/pipeline.ts b/packages/gateway/src/data-plane/openai-embeddings/pipeline.ts new file mode 100644 index 0000000000..8a7685dd51 --- /dev/null +++ b/packages/gateway/src/data-plane/openai-embeddings/pipeline.ts @@ -0,0 +1,25 @@ +import { callOpenAIEmbeddingsUpstream } from './call-upstream.ts'; +import { emitOpenAIEmbeddings } from './emit.ts'; +import type { Fields } from './facts.ts'; +import { narrowing } from './target.ts'; +import { isFailure } from '../pipeline/facts.ts'; +import { failover } from '../pipeline/failover.ts'; +import { resolveCandidates } from '../pipeline/resolve-candidates.ts'; +import { serializeClientJson } from '../pipeline/serialize-client-json.ts'; +import { writeSettlement } from '../pipeline/settlement.ts'; +import { compose, type Pipeline } from '@floway-dev/pipeline'; + +export const openaiEmbeddingsServePipeline: Pipeline< + Fields<'ingress.http.headers' | 'ingress.openaiEmbeddings.encodingFormat' | 'request.openaiEmbeddings.canonical' | 'serve.model'>, + Fields<'response.http.jsonBody' | 'response.openaiEmbeddings.rendered' | 'response.http.status' | 'response.http.headers' | 'response.usage.billable'> +> = compose('openaiEmbeddingsServe', [ + writeSettlement(handedUp => Number(handedUp['response.http.status']) >= 400), + serializeClientJson('response.openaiEmbeddings.rendered'), + emitOpenAIEmbeddings, + resolveCandidates(narrowing), + failover({ + failed: handedUp => isFailure((handedUp as { 'response.openaiEmbeddings.canonical'?: unknown })['response.openaiEmbeddings.canonical']), + owns: ['response.http.body'], + }), + callOpenAIEmbeddingsUpstream, +]); diff --git a/packages/gateway/src/data-plane/openai-embeddings/target.ts b/packages/gateway/src/data-plane/openai-embeddings/target.ts new file mode 100644 index 0000000000..d592f5b111 --- /dev/null +++ b/packages/gateway/src/data-plane/openai-embeddings/target.ts @@ -0,0 +1,14 @@ +import type { Fields } from './facts.ts'; +import type { Narrowing } from '../pipeline/resolve-candidates.ts'; + +/** A candidate that cannot serve *this* request is not a candidate. Saying why is what + * turns an empty list into a 400 a client can act on. */ +export const narrowing: Narrowing> = { + kind: 'embedding', + reject: candidate => candidate.model.endpoints.openaiEmbeddings === undefined + ? 'the upstream does not expose an OpenAI Embeddings endpoint' + : null, + unsupported: model => `Model ${model} does not support the /embeddings endpoint.`, + refuse: (status, message) => ({ 'response.openaiEmbeddings.canonical': { status, message } }), + refuses: ['response.openaiEmbeddings.canonical'], +}; diff --git a/packages/gateway/src/data-plane/openai-images/call-upstream.ts b/packages/gateway/src/data-plane/openai-images/call-upstream.ts new file mode 100644 index 0000000000..f2fd323a0b --- /dev/null +++ b/packages/gateway/src/data-plane/openai-images/call-upstream.ts @@ -0,0 +1,238 @@ +import type { Fields, OpenAIImagesFrames } from './facts.ts'; +import type { UsageQuantities } from '../../repo/types.ts'; +import type { BillableEntity, Failure } from '../pipeline/facts.ts'; +import { providerEntry } from '../pipeline/provider-entry.ts'; +import { providerUsage } from '../pipeline/provider-usage.ts'; +import type { StreamOutcome } from '../pipeline/serve.ts'; +import type { GatewayServices } from '../pipeline/services.ts'; +import { dialFailure, readUpstreamBody, spentBody, retainReader } from '../pipeline/upstream-body.ts'; +import { upstreamPerformanceContext, telemetryModelIdentity } from '../shared/telemetry/attribution.ts'; +import { recordStream } from '@floway-dev/dump'; +import { exchangeResponse } from '@floway-dev/http/pipeline'; +import { defineStage, move, setRelease, defer, type Deferred } from '@floway-dev/pipeline'; +import { upstreamErrorMessage, isEventStreamMediaType, eventFrame, mediaTypeEssence, parseDecimalString } from '@floway-dev/protocols/common'; +import { parseOpenAIImagesResponse, parseOpenAIImagesStream, isOpenAIImagesTerminalEvent, parseOpenAIImagesUsage, OPENAI_IMAGES_MISSING_TERMINAL_MESSAGE, type OpenAIImagesOperation, type CanonicalOpenAIImagesResponse, type CanonicalOpenAIImagesUsage, type CanonicalOpenAIImagesEditsRequest, type OpenAIImagesEditImage, type OpenAIImageEditReference } from '@floway-dev/protocols/openai-images'; +import { isBase64ImageDataUrl, type ProviderRequest, type ProviderResponse, type ProviderOperationPayloads, type PerformanceOperation, type TelemetryModelIdentity, type OpenAIImagesEditsRequest, type OpenAIImagesEditsSource } from '@floway-dev/provider'; + +/** A stream as a value the record can hold, as a wrapper around the generator rather than the + * generator itself. What the wrapper says is where the resource is: the one resource in an + * OpenAI Images run is the upstream's body at `response.http.body`, claimed with `own()`, and + * this keeps a frame view from reading as another. */ + +const PERFORMANCE_OPERATION = { + generations: 'image_generation', + edits: 'image_edit', +} as const satisfies Record; + +/** + * The ending. It dials, reads what came back on the shape the request asked for, and provides + * the canonical answer, the headers that came with it, the raw HTTP body beneath it, and what + * the call is billable for. A failure is a value: an upstream that refused, and one that + * answered with something this protocol cannot read, are both outcomes the fork above can take + * to the next candidate rather than faults that end the run. + */ +export const callOpenAIImagesUpstream = defineStage< + Fields<'ingress.openaiImages.wantsStream' | 'request.openaiImages.canonical' | 'route.attempt' | 'ingress.http.headers'>, + ProviderRequest, + ProviderResponse, + Fields<'response.openaiImages.canonical' | 'response.openaiImages.streamedUsage' | 'response.http.status' | 'response.http.headers' + | 'response.http.body' | 'response.usage.billable'>, + GatewayServices +>({ + name: 'callOpenAIImagesUpstream', + into: { + request: { needs: ['ingress.openaiImages.wantsStream', 'request.openaiImages.canonical', 'route.attempt', 'ingress.http.headers'], consumes: [], provides: ['request.provider.model', 'request.provider.payload', 'request.http.callId', 'request.http.headers'] }, + response: { needs: ['response.http.exchange', 'response.provider.modelKey', 'response.provider.called', 'response.provider.previousCalls', 'response.http.body'], consumes: ['response.http.exchange', 'response.provider.modelKey', 'response.provider.called', 'response.provider.previousCalls'], provides: ['response.openaiImages.canonical', 'response.openaiImages.streamedUsage', 'response.http.status', 'response.http.headers', 'response.http.body', 'response.usage.billable'] }, + }, + execute: async (facts, next, use) => { + const candidate = use.resolveAttempt(facts['route.attempt']); + const request = facts['request.openaiImages.canonical']; + // Attribution is set before the dial, so an attempt that never completes still names the + // candidate it was made against rather than the one tried before it. + use.gateway.attempt.telemetry = upstreamPerformanceContext(use.gateway, candidate, PERFORMANCE_OPERATION[request.operation]); + + const back = request.operation === 'generations' + ? await next(providerEntry(facts, candidate, request.parameters), candidate.provider.pipelines.openaiImagesGenerations!) + : await next(providerEntry(facts, candidate, providerEditsRequest(request)), candidate.provider.pipelines.openaiImagesEdits!); + const { 'response.http.exchange': exchange, 'response.provider.modelKey': modelKey, 'response.provider.called': wasCalled, 'response.provider.previousCalls': _previousCalls, ...rest } = back; + if (exchange.type === 'transportFailure') { + return move({ + ...rest, + 'response.openaiImages.canonical': dialFailure(exchange.error), + 'response.openaiImages.streamedUsage': null, + 'response.http.status': 502, + 'response.http.headers': [], + 'response.http.body': spentBody(null), + 'response.usage.billable': providerUsage(candidate, back, []), + }); + } + const result = { response: exchangeResponse(exchange), modelKey }; + + const identity = telemetryModelIdentity(candidate, result.modelKey); + // What came back, unfiltered: the edge is where a client's view of it is decided. + const headers = [...result.response.headers]; + // An entity with no quantities is how "the upstream was called and reported nothing" is + // said, which is a different situation from reporting zero. + const called: readonly BillableEntity[] = wasCalled ? [{ identity, quantities: {} }] : []; + const read = (canonical: CanonicalOpenAIImagesResponse | Failure, billable: readonly BillableEntity[]) => move({ + ...rest, + 'response.openaiImages.canonical': canonical, + 'response.openaiImages.streamedUsage': null, + 'response.http.status': result.response.status, + 'response.http.headers': headers, + 'response.http.body': spentBody(exchange.body), + 'response.usage.billable': providerUsage(candidate, back, billable), + }); + + if (!result.response.ok) { + await use.log.warn('upstream refused', { status: result.response.status }); + // An upstream error body is JSON like any other body. Reading it here is also what + // leaves a losing attempt with nothing open behind it. + spentBody(exchange.body); + const body = await readUpstreamBody(result.response); + return read({ + status: result.response.status, + message: upstreamErrorMessage(body.json) ?? body.text, + ...('json' in body ? { body: body.json } : {}), + }, called); + } + + const mediaType = result.response.headers.get('content-type'); + // Both halves have to hold. `stream` is what the client asked for, and an upstream that + // ignores it answers the single JSON body the arm below reads — so what settles which + // shape arrived is the media type, and what settles which shape the client is owed is the + // request. + if (facts['ingress.openaiImages.wantsStream'] && isEventStreamMediaType(mediaType)) { + if (result.response.body === null) { + return read({ status: 502, message: 'Upstream returned a streaming response with no body.' }, called); + } + // Usage is observed here, closest to the upstream and on the protocol it spoke, by + // folding the events as they pass — so the reading costs one pass and the client's own + // stream is what drives it. What it finds arrives with the completed event, long after + // this stage has handed up, which is why the entity above carries no quantities. + const metered = meterFrames(result.response.body, identity, use.gateway.abortSignal); + setRelease(exchange.body!, async () => { for await (const _event of metered.frames) { /* drain */ } }); + return move({ + ...rest, + // This protocol's stream is bare events rather than protocol frames, so the record is + // told how one becomes a frame instead of being left to assume. + 'response.openaiImages.canonical': recordStream(metered.frames, use.gateway.dump, eventFrame), + 'response.openaiImages.streamedUsage': defer(metered.outcome.then(outcome => ({ ...outcome, billable: providerUsage(candidate, back, outcome.billable) }))), + 'response.http.status': result.response.status, + 'response.http.headers': headers, + // Releasing this body is reading those events to the end: they are one reader over one + // connection, and a second reader is not something a `ReadableStream` allows. + 'response.http.body': exchange.body, + 'response.usage.billable': providerUsage(candidate, back, called), + }); + } + + spentBody(exchange.body); + const body = await readUpstreamBody(result.response); + if (!('json' in body)) { + // Every protocol the gateway carries is one it fully understands, so a body it cannot + // read is not handed on unread. + const essence = mediaTypeEssence(mediaType) ?? 'no media type'; + await use.log.warn('upstream answered with a body that is not JSON', { status: result.response.status, mediaType: essence }); + return read({ + status: 502, + message: `The upstream answered ${result.response.status} with ${essence}, and the OpenAI Images protocol is JSON.`, + body: body.text, + }, called); + } + + let canonical: CanonicalOpenAIImagesResponse; + try { + canonical = parseOpenAIImagesResponse(body.json); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await use.log.warn('upstream answered with a body the OpenAI Images protocol cannot read', { message }); + return read({ status: 502, message, body: body.json }, called); + } + return read(canonical, wasCalled ? [{ identity, quantities: billed(canonical.usage) }] : []); + }, +}); + +interface MeteredFrames { + readonly frames: OpenAIImagesFrames; + readonly outcome: Deferred; +} + +const meterFrames = ( + body: ReadableStream, + identity: TelemetryModelIdentity, + signal: AbortSignal | undefined, +): MeteredFrames => { + const settlement = Promise.withResolvers(); + // Declared as this run's own unfinished work, so the runner waits for it at teardown where + // it can see it rather than the reading being started and forgotten. + const outcome = defer(settlement.promise); + // Running out without the completed event is what "it did not finish" means, and it is known + // at the same moment the usage is. + let sawTerminal = false; + const frames = retainReader((async function* () { + let usage: CanonicalOpenAIImagesUsage | undefined; + try { + for await (const event of parseOpenAIImagesStream(body, { signal })) { + if (isOpenAIImagesTerminalEvent(event)) { + usage = parseOpenAIImagesUsage(event); + sawTerminal = true; + yield event; + // The image is complete, so there is nothing further to read. An upstream that holds + // the connection open past this point would otherwise keep the client's own stream + // open with it; returning here closes the read, which cancels the upstream. + return; + } + yield event; + } + } finally { + // Reached however the events ended — the completed one, a client that stopped reading, + // or a broken upstream — because what the upstream already metered is billable whatever + // happened to the downstream half. + try { + settlement.resolve({ billable: [{ identity, quantities: billed(usage) }], failed: !sawTerminal }); + } catch (error) { + settlement.reject(error); + throw error; + } + } + // Only an upstream that ended its body without ever completing the image reaches here: the + // arm above returns on the terminal event. A client has been sent partial images and no + // image, which is a failed answer however far it got. + throw new Error(OPENAI_IMAGES_MISSING_TERMINAL_MESSAGE); + })()); + return { frames, outcome }; +}; + +/** An image is billed by the tokens its upstream reports: `BILLING_METRICS` names no per-image + * or per-size unit, and per-size pricing is a selector coordinate rather than a metric, so + * there is nothing else here to record a count against. A reported zero is kept — it says the + * upstream reported, which an absent metric would not. */ +const billed = (usage: CanonicalOpenAIImagesUsage | undefined): UsageQuantities => { + const quantities: UsageQuantities = {}; + if (usage?.inputTokens !== undefined) quantities.input_tokens = parseDecimalString(String(usage.inputTokens)); + if (usage?.inputImageTokens !== undefined) quantities.input_image_tokens = parseDecimalString(String(usage.inputImageTokens)); + if (usage?.outputTokens !== undefined) quantities.output_tokens = parseDecimalString(String(usage.outputTokens)); + if (usage?.outputImageTokens !== undefined) quantities.output_image_tokens = parseDecimalString(String(usage.outputImageTokens)); + return quantities; +}; + +/** The provider's own shape, built where the dial happens. What it needs from a reference is + * whether the data URL inside it can become a file, because that decides whether the edit can + * ride as a multipart form; the canonical fact holds the reference the client wrote and leaves + * that question to the serializer that has it. */ +const providerEditsRequest = (request: CanonicalOpenAIImagesEditsRequest): OpenAIImagesEditsRequest => ({ + images: request.images.map(providerEditsSource), + ...(request.mask === undefined ? {} : { mask: providerEditsSource(request.mask) }), + parameters: request.parameters, +}); + +const providerEditsSource = (image: OpenAIImagesEditImage): OpenAIImagesEditsSource => { + if (image.kind === 'file') { + return { type: 'upload', file: { bytes: image.file.bytes, name: image.file.fileName, type: image.file.mediaType } }; + } + const { reference } = image; + return typeof reference.image_url === 'string' && isBase64ImageDataUrl(reference.image_url) + ? { type: 'inline', reference: reference as OpenAIImageEditReference & { image_url: string } } + : { type: 'reference', reference }; +}; diff --git a/packages/gateway/src/data-plane/openai-images/emit.ts b/packages/gateway/src/data-plane/openai-images/emit.ts new file mode 100644 index 0000000000..cb910c3af0 --- /dev/null +++ b/packages/gateway/src/data-plane/openai-images/emit.ts @@ -0,0 +1,65 @@ +import type { OpenAIImagesFrames, Fields, OpenAIImagesFacts } from './facts.ts'; +import { isFailure } from '../pipeline/facts.ts'; +import { isForwardableUpstreamHeader } from '../shared/upstream-response.ts'; +import { streamReferenceOf } from '@floway-dev/dump'; +import { defineStage, move } from '@floway-dev/pipeline'; +import { renderErrorEnvelope, type SseFrame } from '@floway-dev/protocols/common'; +import { renderOpenAIImagesResponse, openaiImagesStreamEventToSSEFrame, type CanonicalOpenAIImagesResponse } from '@floway-dev/protocols/openai-images'; + +const isFrames = (answer: CanonicalOpenAIImagesResponse | OpenAIImagesFrames): answer is OpenAIImagesFrames => + Symbol.asyncIterator in answer; + +/** + * The outermost edge. It names nothing on the way down — the family has one protocol, so there + * is no request to read to know how to answer — and coming back it renders the answer, keeps + * the upstream headers a client may see, and decides the status. The status is decided here + * rather than carried up because a refusal that never reached an upstream has none to carry. + * + * SSE framing is produced here and nowhere else: below this stage a stream carries protocol + * events, so the same assembly would serve another transport by rendering differently at this + * one point. + */ +export const emitOpenAIImages = defineStage< + Record, + Record, + Fields<'response.openaiImages.canonical' | 'response.http.status' | 'response.http.headers'>, + Fields<'response.openaiImages.rendered' | 'response.http.status' | 'response.http.headers'> +>({ + name: 'emitOpenAIImages', + through: { + request: { needs: [], consumes: [], provides: [] }, + response: { + needs: ['response.openaiImages.canonical', 'response.http.headers', 'response.http.status'], + consumes: ['response.openaiImages.canonical', 'response.http.headers'], + provides: ['response.openaiImages.rendered', 'response.http.status', 'response.http.headers'], + }, + }, + execute: async (facts, next) => { + const back = await next(facts); + const { 'response.openaiImages.canonical': answer, 'response.http.headers': headers, ...rest } = back; + // Vendor traces and quota state stay visible; what an intermediary must strip, and what + // would misdescribe a body this gateway serialized itself, does not. A filter that removed + // nothing hands the same array on, so the record shows no change where none happened. + const forwardable = headers.filter(([name]) => isForwardableUpstreamHeader(name)); + return { + ...rest, + 'response.http.headers': forwardable.length === headers.length ? headers : move(forwardable), + 'response.http.status': isFailure(answer) ? answer.status : back['response.http.status'], + 'response.openaiImages.rendered': move(rendered(answer)), + }; + }, +}); + +const rendered = (answer: OpenAIImagesFacts['response.openaiImages.canonical']): OpenAIImagesFacts['response.openaiImages.rendered'] => + isFailure(answer) ? renderErrorEnvelope(answer.message, answer.body) + : isFrames(answer) ? renderSSE(answer) + : renderOpenAIImagesResponse(answer); + +const renderSSE = (frames: OpenAIImagesFrames): AsyncIterable => ({ + // The frames the client reads are a reframing of the ones the record holds, so this key + // points at that same stream rather than at nothing. + ...streamReferenceOf(frames), + [Symbol.asyncIterator]: () => (async function* () { + for await (const event of frames) yield openaiImagesStreamEventToSSEFrame(event); + })(), +}); diff --git a/packages/gateway/src/data-plane/openai-images/facts.ts b/packages/gateway/src/data-plane/openai-images/facts.ts new file mode 100644 index 0000000000..76e8d3e1c2 --- /dev/null +++ b/packages/gateway/src/data-plane/openai-images/facts.ts @@ -0,0 +1,47 @@ +import type { GatewayFacts, Failure } from '../pipeline/facts.ts'; +import type { StreamOutcome } from '../pipeline/serve.ts'; +import type { Deferred } from '@floway-dev/pipeline'; +import type { SseFrame } from '@floway-dev/protocols/common'; +import type { OpenAIImagesStreamEvent, CanonicalOpenAIImagesRequest, CanonicalOpenAIImagesResponse } from '@floway-dev/protocols/openai-images'; + +/** The answer while it is still the upstream's, one event at a time. There is no transport + * sentinel below the events — this protocol ends at its completed event — so what travels is + * the events themselves rather than frames with a terminal arm the protocol has not got. */ +export type OpenAIImagesFrames = AsyncIterable; + +/** OpenAI Images' own keys. They extend the shared space and never merge into it, so a stage + * written against the gateway alone cannot name one. */ +export interface OpenAIImagesFacts extends GatewayFacts { + /** Whether the client asked for the answer as a stream. It stays put, as every `ingress.*` + * key does: the same flag travels to the upstream inside the parameters, and the answer is + * written back in the shape it asked for. */ + 'ingress.openaiImages.wantsStream': boolean; + 'request.openaiImages.canonical': CanonicalOpenAIImagesRequest; + /** The answer, whichever kind it turned out to be. A stream, a value and a failure sit at + * one key: telling them apart is reading a value, and each stage does that where it needs + * to. */ + 'response.openaiImages.canonical': CanonicalOpenAIImagesResponse | OpenAIImagesFrames | Failure; + /** What the upstream will have reported by the time the events run out, and `null` on every + * path that does not stream. A streamed image states its usage in the completed event, + * which is after this run has answered — so the numbers cannot be in + * `response.usage.billable`, which says what had been reported when the ending stage handed + * up: the entity, and no quantities. Settling from this is the epilogue's job, after the + * drain. */ + 'response.openaiImages.streamedUsage': Deferred | null; + /** What the client is actually sent, in the OpenAI Images protocol — a JSON body, or the SSE + * frames of a stream. The edge provides it, so a dump shows the body the client received + * rather than the gateway's canonical form. */ + 'response.openaiImages.rendered': Record | AsyncIterable; +} + +export type Fields = { [P in K]: OpenAIImagesFacts[P] }; + +/** What a caller must bring. `ingress.http.headers`, `ingress.openaiImages.wantsStream` and + * `request.openaiImages.canonical` are in it although `compose` cannot derive them — the ending + * stage reads all three and a return-only stage declares no request side — so this type is the + * whole statement and `entryNeeds` is part of it. */ +export type OpenAIImagesServeEntry = Fields<'ingress.http.headers' | 'ingress.openaiImages.wantsStream' | 'request.openaiImages.canonical' | 'serve.model'>; + +export type OpenAIImagesServeExit = Fields< + 'response.http.jsonBody' | 'response.openaiImages.rendered' | 'response.openaiImages.streamedUsage' | 'response.http.status' | 'response.http.headers' | 'response.usage.billable' +>; diff --git a/packages/gateway/src/data-plane/openai-images/http.ts b/packages/gateway/src/data-plane/openai-images/http.ts index 76a68bd627..f1d437ac88 100644 --- a/packages/gateway/src/data-plane/openai-images/http.ts +++ b/packages/gateway/src/data-plane/openai-images/http.ts @@ -1,174 +1,74 @@ -// POST /v1/images/generations and POST /v1/images/edits — route image -// requests to the provider that declares the requested model and the -// matching image endpoint capability. +// POST /v1/images/generations and POST /v1/images/edits, served through the pipeline. // -// The edits handler accepts multipart uploads and JSON `images` references. -// Both are buffered once for dump capture and normalized into a semantic -// request; each provider owns the final JSON or multipart serialization. -// https://github.com/openai/openai-openapi/blob/a3276900e58b8b2a92e0cb087cd2e6e005f58458/openapi.yaml#L12558-L12620 +// Two endpoints and one family. What the endpoint decides is which contract reads the body — +// generations is always JSON, edits is JSON or a multipart form carrying the files — and +// after that both hand the same canonical request to the same run. import type { Context } from 'hono'; -import { backgroundSchedulerFromContext } from '../../runtime/background.ts'; -import { createGatewayCtxFromHono, finalizeGatewayResponse } from '../shared/gateway-ctx.ts'; -import { prepareJsonModelRequest } from '../shared/passthrough-request.ts'; -import { passthroughApiError, passthroughServe } from '../shared/passthrough-serve.ts'; -import { readRequestBody, takeRequestBody, type RequestBody } from '../shared/request-body.ts'; -import { tokenUsageFromOpenAIImagesBody } from '../shared/telemetry/usage.ts'; -import { isJsonMediaType, isMultipartFormDataMediaType } from '@floway-dev/protocols/common'; -import type { OpenAIImageEditReference } from '@floway-dev/protocols/openai-images'; -import { isBase64ImageDataUrl, type OpenAIImagesEditsRequest, type OpenAIImagesEditsSource } from '@floway-dev/provider'; +import { openaiImagesServePipeline } from './pipeline.ts'; +import { isFrames, openPrologue, readIngress, serveThrough, type Ingress } from '../pipeline/serve.ts'; +import { finalizeGatewayResponse } from '../shared/gateway-ctx.ts'; +import { move } from '@floway-dev/pipeline'; +import { openaiImagesRequestWantsStream, parseOpenAIImagesEditsRequest, parseOpenAIImagesGenerationsRequest, type ParsedOpenAIImagesRequest } from '@floway-dev/protocols/openai-images'; -type PreparedOpenAIImagesEdit = - | { type: 'ok'; request: OpenAIImagesEditsRequest } - | { type: 'invalid'; message: string }; - -const openaiImageEditSource = (value: unknown, path: string): OpenAIImagesEditsSource | string => { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - return `${path} must be an object.`; - } - const reference = value as { image_url?: unknown; file_id?: unknown }; - const { image_url: imageUrl, file_id: fileId } = reference; - if (typeof imageUrl === 'string' && fileId === undefined) { - const imageReference = reference as OpenAIImageEditReference & { image_url: string }; - return isBase64ImageDataUrl(imageUrl) - ? { type: 'inline', reference: imageReference } - : { type: 'reference', reference: imageReference }; - } - if (typeof fileId === 'string' && imageUrl === undefined) { - return { type: 'reference', reference: reference as OpenAIImageEditReference }; - } - return `${path} must contain exactly one string field: image_url or file_id.`; -}; - -const prepareJsonOpenAIImagesEdit = (body: Record): PreparedOpenAIImagesEdit => { - if (!Array.isArray(body.images)) { - return { type: 'invalid', message: 'OpenAI Images Edits request body must include an images array.' }; - } - const images: OpenAIImagesEditsSource[] = []; - for (const [index, value] of body.images.entries()) { - const source = openaiImageEditSource(value, `OpenAI Images Edits images[${index}]`); - if (typeof source === 'string') return { type: 'invalid', message: source }; - images.push(source); - } - let mask: OpenAIImagesEditsSource | undefined; - if (body.mask !== undefined) { - const source = openaiImageEditSource(body.mask, 'OpenAI Images Edits mask'); - if (typeof source === 'string') return { type: 'invalid', message: source }; - mask = source; +/** The half both endpoints share: whatever the contract read, hand it over and turn what the + * run answered with into a response. The contract reports a malformed request by throwing; + * what the client is owed is a 400 carrying the reason. */ +const serveOpenAIImages = async ( + c: Context, + ingress: Ingress, + read: () => ParsedOpenAIImagesRequest | Promise, +): Promise => { + let parsed: ParsedOpenAIImagesRequest; + try { + parsed = await read(); + } catch (error) { + // A request the gateway could not read never reaches a pipeline: there is no model to + // resolve and no attempt to make, so there is nothing for a run to record. + const refused = openPrologue(c, ingress, { wantsStream: false }); + refused.gateway.dump?.error('gateway'); + return finalizeGatewayResponse( + refused.gateway, + Response.json( + { error: { message: error instanceof Error ? error.message : String(error), type: 'api_error' } }, + { status: 400 }, + ), + ); } - const { model: _model, images: _images, mask: _mask, ...parameters } = body; - return { - type: 'ok', - request: { - images, - ...(mask === undefined ? {} : { mask }), - parameters, - }, - }; -}; -export const openaiImagesGenerations = async (c: Context): Promise => { - const requestBody = await readRequestBody(c); - const request = prepareJsonModelRequest(requestBody.bytes, 'OpenAI Images Generations'); - const ctx = createGatewayCtxFromHono(c, { wantsStream: false, requestBody: takeRequestBody(requestBody), backgroundScheduler: backgroundSchedulerFromContext(c) }); - if (request.type === 'invalid') { - ctx.dump?.error('gateway'); - return finalizeGatewayResponse(ctx, passthroughApiError(c, request.message, 400)); - } + const { model, request } = parsed; + // Whether the answer streams is written in the request the client sent, and the run has to be + // opened knowing it: the abort controller a streaming run cancels its read with is minted + // from this flag. + const wantsStream = openaiImagesRequestWantsStream(request); + const prologue = openPrologue(c, ingress, { wantsStream, model }); - ctx.dump?.requestedModel(request.model); - const response = await passthroughServe({ + return await serveThrough( c, - ctx, - sourceApi: '/images/generations', - operation: 'image_generation', - model: request.model, - kind: 'image', - modelServesEndpoint: model => model.endpoints.openaiImagesGenerations !== undefined, - call: (provider, model, opts) => { - const { model: _model, ...body } = request.body; - return provider.instance.callOpenAIImagesGenerations(model, body, undefined, opts); + prologue, + openaiImagesServePipeline(request), + move({ + 'ingress.http.headers': prologue.headers, + 'ingress.openaiImages.wantsStream': wantsStream, + 'request.openaiImages.canonical': request, + 'serve.model': model, + }) as never, + facts => { + const answer = facts['response.openaiImages.rendered']; + return isFrames(answer) ? { frames: answer } : { body: facts['response.http.jsonBody']!, contentType: 'application/json' }; }, - response: { format: 'json', extractBilling: tokenUsageFromOpenAIImagesBody }, - }); - return finalizeGatewayResponse(ctx, response); + ); }; -const serveOpenAIImagesEditRequest = async ( - c: Context, - requestBody: RequestBody, - model: string, - request: OpenAIImagesEditsRequest, -): Promise => { - const ctx = createGatewayCtxFromHono(c, { wantsStream: false, requestBody: takeRequestBody(requestBody), backgroundScheduler: backgroundSchedulerFromContext(c) }); - ctx.dump?.requestedModel(model); - const response = await passthroughServe({ - c, - ctx, - sourceApi: '/images/edits', - operation: 'image_edit', - model, - kind: 'image', - modelServesEndpoint: model => model.endpoints.openaiImagesEdits !== undefined, - call: (provider, model, opts) => provider.instance.callOpenAIImagesEdits(model, request, undefined, opts), - response: { format: 'json', extractBilling: tokenUsageFromOpenAIImagesBody }, - }); - return finalizeGatewayResponse(ctx, response); +export const openaiImagesGenerations = async (c: Context): Promise => { + const ingress = await readIngress(c); + return await serveOpenAIImages(c, ingress, () => parseOpenAIImagesGenerationsRequest(ingress.body.bytes)); }; export const openaiImagesEdits = async (c: Context): Promise => { - const requestBody = await readRequestBody(c); - const invalid = (message: string): Response => { - const errorCtx = createGatewayCtxFromHono(c, { wantsStream: false, requestBody: takeRequestBody(requestBody), backgroundScheduler: backgroundSchedulerFromContext(c) }); - errorCtx.dump?.error('gateway'); - return finalizeGatewayResponse(errorCtx, passthroughApiError(c, message, 400)); - }; - - const contentType = c.req.header('content-type'); - if (contentType === undefined) { - return invalid('OpenAI Images Edits request body must use application/json or multipart/form-data.'); - } - if (isJsonMediaType(contentType)) { - const body = prepareJsonModelRequest(requestBody.bytes, 'OpenAI Images Edits'); - if (body.type === 'invalid') return invalid(body.message); - const request = prepareJsonOpenAIImagesEdit(body.body); - if (request.type === 'invalid') return invalid(request.message); - return await serveOpenAIImagesEditRequest(c, requestBody, body.model, request.request); - } - - if (!isMultipartFormDataMediaType(contentType)) { - return invalid('OpenAI Images Edits request body must use application/json or multipart/form-data.'); - } - let form: FormData; - try { - form = await new Response(requestBody.bytes as BodyInit, { headers: { 'content-type': contentType } }).formData(); - } catch { - return invalid('OpenAI Images Edits request body must be valid multipart/form-data.'); - } - const model = form.get('model'); - if (typeof model !== 'string' || model.length === 0) { - return invalid('OpenAI Images Edits request body must include a model field.'); - } - const images: File[] = []; - let mask: File | undefined; - const parameters: Record = {}; - for (const [name, value] of form.entries()) { - if (name === 'model') continue; - if (name === 'image' || name === 'image[]') { - if (!(value instanceof File)) return invalid(`OpenAI Images Edits ${name} fields must be files.`); - images.push(value); - } else if (name === 'mask') { - if (!(value instanceof File)) return invalid('OpenAI Images Edits mask field must be a file.'); - mask = value; - } else { - if (typeof value !== 'string') return invalid(`OpenAI Images Edits ${name} field must be text.`); - parameters[name] = value; - } - } - return await serveOpenAIImagesEditRequest(c, requestBody, model, { - images: images.map(file => ({ type: 'upload', file })), - ...(mask === undefined ? {} : { mask: { type: 'upload' as const, file: mask } }), - parameters, - }); + const ingress = await readIngress(c); + // Which of the two bodies arrived is a header's statement and not the payload's, so the + // contract is handed the media type alongside the bytes. + return await serveOpenAIImages(c, ingress, () => parseOpenAIImagesEditsRequest(c.req.header('content-type'), ingress.body.bytes)); }; diff --git a/packages/gateway/src/data-plane/openai-images/pipeline.ts b/packages/gateway/src/data-plane/openai-images/pipeline.ts new file mode 100644 index 0000000000..8e5449412a --- /dev/null +++ b/packages/gateway/src/data-plane/openai-images/pipeline.ts @@ -0,0 +1,25 @@ +import { callOpenAIImagesUpstream } from './call-upstream.ts'; +import { emitOpenAIImages } from './emit.ts'; +import type { OpenAIImagesServeEntry, OpenAIImagesServeExit } from './facts.ts'; +import { narrowing } from './target.ts'; +import { isFailure } from '../pipeline/facts.ts'; +import { failover } from '../pipeline/failover.ts'; +import { resolveCandidates } from '../pipeline/resolve-candidates.ts'; +import { serializeClientJson } from '../pipeline/serialize-client-json.ts'; +import { writeSettlement } from '../pipeline/settlement.ts'; +import { compose, type Pipeline } from '@floway-dev/pipeline'; +import type { CanonicalOpenAIImagesRequest } from '@floway-dev/protocols/openai-images'; + +export const openaiImagesServePipeline = (request: CanonicalOpenAIImagesRequest): Pipeline => + compose('openaiImagesServe', [ + writeSettlement(handedUp => Number(handedUp['response.http.status']) >= 400, 'response.openaiImages.streamedUsage'), + serializeClientJson('response.openaiImages.rendered'), + emitOpenAIImages, + resolveCandidates(narrowing(request)), + failover({ + failed: handedUp => isFailure((handedUp as { 'response.openaiImages.canonical'?: unknown })['response.openaiImages.canonical']), + owns: ['response.http.body'], + pendingUsage: 'response.openaiImages.streamedUsage', + }), + callOpenAIImagesUpstream, + ]); diff --git a/packages/gateway/src/data-plane/openai-images/target.ts b/packages/gateway/src/data-plane/openai-images/target.ts new file mode 100644 index 0000000000..bc83782a13 --- /dev/null +++ b/packages/gateway/src/data-plane/openai-images/target.ts @@ -0,0 +1,25 @@ +import type { Failure } from '../pipeline/facts.ts'; +import type { ModelEndpointKey } from '@floway-dev/protocols/common'; +import type { OpenAIImagesOperation, CanonicalOpenAIImagesRequest } from '@floway-dev/protocols/openai-images'; +import type { ModelCandidate } from '@floway-dev/provider'; + +const ENDPOINT = { + generations: 'openaiImagesGenerations', + edits: 'openaiImagesEdits', +} as const satisfies Record; + +/** A candidate that cannot serve *this* request is not a candidate. One family covers two + * endpoints and an upstream may expose either without the other, so which one is asked for is + * what narrows the list. */ +export const narrowing = (request: CanonicalOpenAIImagesRequest) => ({ + kind: 'image' as const, + reject: (candidate: ModelCandidate) => candidate.model.endpoints[ENDPOINT[request.operation]] === undefined + ? `the upstream does not expose the OpenAI Images ${request.operation} endpoint` + : null, + unsupported: (model: string) => `Model ${model} does not support the /images/${request.operation} endpoint.`, + refuse: (status: number, message: string) => ({ + 'response.openaiImages.canonical': { status, message } as Failure, + 'response.openaiImages.streamedUsage': null, + }), + refuses: ['response.openaiImages.canonical', 'response.openaiImages.streamedUsage'] as const, +}); diff --git a/packages/gateway/src/data-plane/pipeline/candidates.ts b/packages/gateway/src/data-plane/pipeline/candidates.ts new file mode 100644 index 0000000000..40362d93c9 --- /dev/null +++ b/packages/gateway/src/data-plane/pipeline/candidates.ts @@ -0,0 +1,27 @@ +import type { AttemptSelector } from './facts.ts'; +import { providerModelOf, type ModelCandidate } from '@floway-dev/provider'; + +export const createCandidateRegistry = () => { + const live: ModelCandidate[] = []; + const resolveCandidate = (candidateId: number): ModelCandidate => { + const candidate = live[candidateId]; + if (candidate === undefined) throw new Error(`resolveAttempt: nothing live for candidate ${candidateId}; the selector did not come from this run`); + return candidate; + }; + return { + resolveCandidate, + rememberCandidates: (candidates: readonly ModelCandidate[]): readonly AttemptSelector[] => candidates.map(candidate => { + const candidateId = live.length; + live.push(candidate); + return { + candidateId, + upstreamId: candidate.provider.upstreamId, + modelId: candidate.model.id, + flags: [...providerModelOf(candidate).enabledFlags], + }; + }), + resolveAttempt: (selector: AttemptSelector): ModelCandidate => { + return resolveCandidate(selector.candidateId); + }, + }; +}; diff --git a/packages/gateway/src/data-plane/pipeline/facts.ts b/packages/gateway/src/data-plane/pipeline/facts.ts new file mode 100644 index 0000000000..401fdb9e5e --- /dev/null +++ b/packages/gateway/src/data-plane/pipeline/facts.ts @@ -0,0 +1,77 @@ +// Shared gateway content facts. Live providers, fetchers and model caches remain in services. + +import type { UsageQuantities } from '../../repo/types.ts'; +import type { Secret, Owned } from '@floway-dev/pipeline'; +import type { PricingRuntimeFacts } from '@floway-dev/protocols/common'; +import type { TelemetryModelIdentity } from '@floway-dev/provider'; + +/** Everything about an attempt that is data: which upstream, which model row on it, and the + * flags that row carries. Enough to choose, to record and to price — and to look the live + * candidate back up when the time comes to dial. */ +export interface AttemptSelector { + readonly candidateId: number; + readonly upstreamId: string; + readonly modelId: string; + /** Snapshotted rather than referenced, because the record must show what was true when + * the attempt was made rather than what the row says now. */ + readonly flags: readonly string[]; +} + +/** What an upstream call is answerable for. Keyed by billed entity, because one call can + * bill in units that are not commensurable, and an entity with no quantities at all is + * how "the upstream was called and reported nothing" is said. */ +export interface BillableEntity { + readonly identity: TelemetryModelIdentity; + readonly quantities: UsageQuantities; + /** What pricing needs beyond the quantities, when a rate depends on more than how much + * there was. Absent is a real reading and not a missing one: most families price on the + * quantities alone. Observed where the reading is, because settlement is the last reader + * and not a second observer. */ + readonly pricingFacts?: PricingRuntimeFacts; +} + +/** A failure is a value, never a throw, so an earlier stage can fail over a later stage's + * fault — even a 400, because the path and the flags may differ on the next candidate. */ +export interface Failure { + readonly status: number; + readonly message: string; + /** Complete parsed upstream error content when available. */ + readonly body?: unknown; +} + +export const isFailure = (value: unknown): value is Failure => + typeof value === 'object' && value !== null && 'status' in value && 'message' in value; + +export interface GatewayFacts { + /** What the client sent, before anything read it. Every family hands these over, because + * every ending forwards what a provider is allowed to forward of them. */ + 'ingress.http.headers': readonly (readonly [string, string])[]; + + /** The public model id the client asked for, and the candidates it resolves to. Nothing + * consumes these: they outlive an attempt. */ + 'serve.model': string; + 'serve.candidates': readonly AttemptSelector[]; + + /** A selector resolves a live candidate without freezing the provider's mutable catalog. */ + 'route.attempt': AttemptSelector; + + /** Admitted headers remain values until the provider shapes its authenticated HTTP request. */ + 'request.http.headers': readonly (readonly [string, string | Secret])[]; + + 'response.http.status': number; + /** Serialized client JSON, or null for a protocol stream or an upstream document. */ + 'response.http.jsonBody': Uint8Array | null; + 'response.http.headers': readonly (readonly [string, string])[]; + /** The upstream's body, still open, and marked as something the run answers for. `Owned` + * rather than `AsyncDisposable`, because a structural type would say what the host happens + * to mark rather than what this run answers for — and what a host marks differs between the + * Node versions this ships on. Ownership is claimed, so the type says so too. */ + 'response.http.body': (ReadableStream & Owned) | null; + + /** The authoritative reading, provided closest to the upstream on the dialect it + * actually spoke. Every step that changes usage re-provides it. */ + 'response.usage.billable': readonly BillableEntity[]; + + /** Completed calls carried into a nested attempt so an exceptional exit can settle them. */ + 'serve.usage.prior': readonly BillableEntity[]; +} diff --git a/packages/gateway/src/data-plane/pipeline/failover.ts b/packages/gateway/src/data-plane/pipeline/failover.ts new file mode 100644 index 0000000000..da3ebfba00 --- /dev/null +++ b/packages/gateway/src/data-plane/pipeline/failover.ts @@ -0,0 +1,79 @@ +import type { BillableEntity } from './facts.ts'; +import type { StreamOutcome } from './serve.ts'; +import type { GatewayServices } from './services.ts'; +import type { Slice } from './stage-contracts.ts'; +import { defineStage, move, defer, type Deferred, type Facts } from '@floway-dev/pipeline'; + +/** + * Runs what follows it once per candidate and returns the first that did not fail. + * + * The losing attempts' bodies are its own — that is what `consumes` declares on the way up + * — and the winner's rides onward, which is what `provides` declares. Failover never + * breaks a stream: once one has opened there is nothing to fail over to, and the family's + * edge is what settles that by sitting above this stage rather than below it. + */ +export interface Forking { + /** How this family reads an attempt's outcome. Branching is the framework's; what + * something branches on is the domain's. */ + readonly failed: (handedUp: Facts) => boolean; + /** The keys at which this family's attempts hand up something the run owns — an upstream + * body still open, most often. A family whose ending reads its answer to the end owns + * nothing and names nothing here, and one that streams names the key it streams at. + * + * It cannot be a fixed key. Declaring `provides` for a key a family never produces makes + * the runner throw on the first real request, and declaring `consumes` for one it does + * produce and hands up makes it throw the other way. Which keys carry a resource is a + * statement only the family can make. */ + readonly owns: readonly string[]; + readonly pendingUsage?: string; +} + +export const failover = ({ failed, owns, pendingUsage }: Forking) => defineStage< + Slice<'serve.candidates' | 'serve.usage.prior'>, + Slice<'serve.candidates' | 'route.attempt' | 'serve.usage.prior'>, + Slice<'response.usage.billable'>, + Slice<'response.usage.billable' | 'serve.usage.prior'>, + GatewayServices +>({ + name: 'failover', + through: { + request: { needs: ['serve.candidates', 'serve.usage.prior'], consumes: [], provides: ['route.attempt', 'serve.usage.prior'] }, + response: { + needs: ['response.usage.billable', ...(pendingUsage === undefined ? [] : [pendingUsage])] as never, + // Owned on the way up and handed onward: every attempt's is this stage's to release, + // and the one it adopts rides up with ownership going with it. + consumes: owns as never, + provides: [...owns, 'response.usage.billable', 'serve.usage.prior', ...(pendingUsage === undefined ? [] : [pendingUsage])] as never, + }, + }, + execute: async (facts, next, use) => { + let last: Slice<'response.usage.billable'> | undefined; + let observed: readonly BillableEntity[] = []; + for (const candidate of facts['serve.candidates']) { + // Per-attempt telemetry state, cleared before control leaves, so a mid-attempt throw + // still attributes its performance row to the candidate that was being tried. + use.gateway.attempt.timing.upstreamCallStartedAt = null; + use.gateway.attempt.timing.firstOutputTokenAt = null; + last = await next({ ...facts, 'route.attempt': move(candidate), 'serve.usage.prior': move([...facts['serve.usage.prior'], ...observed]) }); + const prior = observed; + observed = [...observed, ...last['response.usage.billable']]; + if (!failed(last as Facts)) { + if (prior.length === 0) return { ...last, 'serve.usage.prior': facts['serve.usage.prior'] }; + const pending = pendingUsage === undefined ? null : (last as Facts)[pendingUsage] as Deferred | null; + return { + ...last, + 'serve.usage.prior': facts['serve.usage.prior'], + 'response.usage.billable': move(observed), + ...(pending === null ? {} : { + [pendingUsage!]: move(defer(pending.then(outcome => ({ ...outcome, billable: [...prior, ...outcome.billable] })))), + }), + }; + } + await use.log.info('candidate failed, trying the next', { upstream: candidate.upstreamId }); + } + if (last === undefined) throw new Error('failover: assembly handed it an empty candidate list'); + // Every candidate failed, and the last failure is the base — so the client sees real + // upstream telemetry rather than a synthesized gateway envelope. + return { ...last, 'serve.usage.prior': facts['serve.usage.prior'], 'response.usage.billable': move(observed) }; + }, +}); diff --git a/packages/gateway/src/data-plane/pipeline/provider-entry.ts b/packages/gateway/src/data-plane/pipeline/provider-entry.ts new file mode 100644 index 0000000000..4ea7645f5c --- /dev/null +++ b/packages/gateway/src/data-plane/pipeline/provider-entry.ts @@ -0,0 +1,20 @@ +import type { AttemptSelector } from './facts.ts'; +import { filterInboundHeadersForProvider } from '../shared/inbound-headers.ts'; +import { move } from '@floway-dev/pipeline'; +import { providerModelFacts, providerModelOf, type ModelCandidate, type ProviderRequest } from '@floway-dev/provider'; + +export const providerEntry =

( + facts: { 'route.attempt': AttemptSelector; 'ingress.http.headers': readonly (readonly [string, string])[] }, + candidate: ModelCandidate, + payload: P, + forwardedHeaders: readonly (readonly [string, string])[] = facts['ingress.http.headers'], +): ProviderRequest

& Record => move({ + ...facts, + 'request.provider.model': providerModelFacts(providerModelOf(candidate)), + 'request.provider.payload': payload, + 'request.http.callId': facts['route.attempt'].candidateId, + 'request.http.headers': [...filterInboundHeadersForProvider( + new Headers(forwardedHeaders.map(([name, value]): [string, string] => [name, value])), + candidate.provider, + )], +}); diff --git a/packages/gateway/src/data-plane/pipeline/provider-usage.ts b/packages/gateway/src/data-plane/pipeline/provider-usage.ts new file mode 100644 index 0000000000..e96ef0556f --- /dev/null +++ b/packages/gateway/src/data-plane/pipeline/provider-usage.ts @@ -0,0 +1,25 @@ +import type { BillableEntity } from './facts.ts'; +import { telemetryModelIdentity } from '../shared/telemetry/attribution.ts'; +import type { Facts } from '@floway-dev/pipeline'; +import type { ModelCandidate, ProviderResponse } from '@floway-dev/provider'; + +export const providerUsage = ( + candidate: ModelCandidate, + response: Pick, + finalCall: readonly BillableEntity[], +): readonly BillableEntity[] => { + const previous = response['response.provider.previousCalls']; + if (previous.length === 0) return finalCall; + return [ + ...previous.map(call => ({ identity: telemetryModelIdentity(candidate, call.modelKey), quantities: {} })), + ...finalCall, + ]; +}; + +export const providerCalls = (candidate: ModelCandidate, facts: Facts): readonly BillableEntity[] => { + if (!('response.provider.called' in facts)) return []; + const response = facts as unknown as ProviderResponse; + const current: readonly BillableEntity[] = response['response.provider.called'] + ? [{ identity: telemetryModelIdentity(candidate, response['response.provider.modelKey']), quantities: {} }] : []; + return providerUsage(candidate, response, current); +}; diff --git a/packages/gateway/src/data-plane/pipeline/resolve-candidates.ts b/packages/gateway/src/data-plane/pipeline/resolve-candidates.ts new file mode 100644 index 0000000000..953af1f3dc --- /dev/null +++ b/packages/gateway/src/data-plane/pipeline/resolve-candidates.ts @@ -0,0 +1,98 @@ +import type { GatewayServices } from './services.ts'; +import type { Slice } from './stage-contracts.ts'; +import { enumerateModelCandidates } from '../providers/resolution.ts'; +import { appendFailedUpstreams } from '../shared/failed-upstreams.ts'; +import { defineStage, move } from '@floway-dev/pipeline'; +import type { ModelKind } from '@floway-dev/protocols/common'; +import type { ModelCandidate } from '@floway-dev/provider'; + +/** What a family narrows its candidates by, and what it says when nothing is left. A + * candidate that resolves but cannot serve this request — no endpoint for the kind, or a + * payload the target protocol cannot express — is not a candidate, and saying why is what + * turns an empty list into a usable 400 rather than a bare 404. */ +export interface Narrowing { + readonly kind: ModelKind; + /** Keeps a candidate, or says in one phrase why it cannot serve this request. */ + readonly reject: (candidate: ModelCandidate) => string | null; + /** What the client is told when the model resolved but nothing can serve the request. + * `reasons` holds what `reject` said, and is empty when no candidate of this kind was + * found at all — which is the difference between "this model is not an embeddings model" + * and "this reranker cannot do what this request asks". Families differ in how much of + * that they spell out, so the sentence is the family's rather than this stage's. */ + readonly unsupported: (model: string, reasons: readonly string[]) => string; + /** What this family answers with when there is no candidate to try. The family decides + * which of its own keys carries the failure, which is why the refusal slice is a type + * parameter rather than a shared key: a reusable stage whose short-circuit provides + * family-specific keys cannot be written against the shared space alone. */ + readonly refuse: (status: number, message: string) => Refusal; + /** The same keys as strings, so assembly can check that a short-circuit here covers what + * the stages above it need. */ + readonly refuses: readonly (keyof Refusal)[]; +} + +/** + * Provides `serve.candidates`, or answers with the failure that says why there are none — + * both traits, because "no upstream serves this model" is an answer this stage already + * holds and nothing below it could produce one. + */ +export const resolveCandidates = (narrowing: Narrowing) => defineStage< + Slice<'serve.model'>, // what arrives + Slice<'serve.model' | 'serve.candidates'>, // what it hands down + Slice<'response.usage.billable' | 'response.http.headers' | 'response.http.status'>, // what comes back + Slice<'response.usage.billable' | 'response.http.headers' | 'response.http.status'>, // what it hands up, having descended + Slice<'response.usage.billable' | 'response.http.headers' | 'response.http.status'> & Refusal, // and what it answers with instead + GatewayServices +>({ + name: 'resolveCandidates', + through: { + request: { needs: ['serve.model'], consumes: [], provides: ['serve.candidates'] }, + response: { needs: ['response.usage.billable', 'response.http.headers'], consumes: [], provides: [] }, + }, + return: { provides: ['response.usage.billable', 'response.http.headers', 'response.http.status', ...narrowing.refuses] }, + execute: async (facts, next, use) => { + const model = facts['serve.model']; + const { candidates, sawModel, failedUpstreams } = await enumerateModelCandidates({ + subject: use.gateway.user, + upstreamIds: use.gateway.upstreamIds, + randomizeUpstreams: use.gateway.randomizeUpstreams, + model, + kind: narrowing.kind, + scheduler: use.gateway.backgroundScheduler, + runtimeLocation: use.gateway.runtimeLocation, + }); + + // An empty billed set is what "we did not call an upstream" looks like, and an empty + // header list is the same statement on the other key. The settlement stages still run + // and still write; the row simply names no billed entity. + const refuse = (status: number, message: string) => + move({ + ...facts, + 'response.usage.billable': [], + 'response.http.headers': [], + 'response.http.status': status, + ...narrowing.refuse(status, message), + }); + + if (candidates.length === 0) { + const missing = sawModel + ? narrowing.unsupported(model, []) + : `Model ${model} is not available on any configured upstream.`; + return refuse(sawModel ? 400 : 404, appendFailedUpstreams(missing, failedUpstreams)); + } + + const refused = new Set(); + const viable = candidates.filter(candidate => { + const why = narrowing.reject(candidate); + if (why !== null) refused.add(why); + return why === null; + }); + // The live half stays with the resolver; only selectors travel. + if (viable.length === 0) { + await use.log.debug('no viable candidate', { model, refused: [...refused] }); + return refuse(400, appendFailedUpstreams(narrowing.unsupported(model, [...refused]), failedUpstreams)); + } + + await use.log.debug('resolved candidates', { model, viable: viable.length, resolved: candidates.length }); + return await next({ ...facts, 'serve.candidates': move(use.rememberCandidates(viable)) }); + }, +}); diff --git a/packages/gateway/src/data-plane/pipeline/serialize-client-json.ts b/packages/gateway/src/data-plane/pipeline/serialize-client-json.ts new file mode 100644 index 0000000000..299ac9bb0e --- /dev/null +++ b/packages/gateway/src/data-plane/pipeline/serialize-client-json.ts @@ -0,0 +1,26 @@ +import type { GatewayFacts } from './facts.ts'; +import { defineStage, move } from '@floway-dev/pipeline'; + +export const serializeClientJson = (renderedKey: K) => defineStage< + Record, + Record, + Record, + Pick +>({ + name: 'serializeClientJson', + through: { + request: { needs: [], consumes: [], provides: [] }, + response: { needs: [renderedKey], consumes: [], provides: ['response.http.jsonBody'] }, + }, + execute: async (facts, next) => { + const back = await next(facts); + const rendered = back[renderedKey]; + if (typeof rendered === 'object' && rendered !== null + && (rendered instanceof Uint8Array || Symbol.asyncIterator in rendered)) { + return move({ ...back, 'response.http.jsonBody': null }); + } + const json = JSON.stringify(rendered); + if (json === undefined) throw new TypeError('Client JSON body has no JSON representation'); + return move({ ...back, 'response.http.jsonBody': new TextEncoder().encode(json) }); + }, +}); diff --git a/packages/gateway/src/data-plane/pipeline/serve.ts b/packages/gateway/src/data-plane/pipeline/serve.ts new file mode 100644 index 0000000000..002118c6e3 --- /dev/null +++ b/packages/gateway/src/data-plane/pipeline/serve.ts @@ -0,0 +1,253 @@ +// The seam. Every family's route handler does the same things around its pipeline — read +// the body, build what the run is given, run it, turn the exit facts into a response — so +// it is written once here and each family adds only its own keys. +// +// This is also where "entering the pipeline system requires no capability" is concrete: a +// handler calls `run` like any other caller. What makes a run *recorded* is the prologue +// resolving a dump sink, not a flag anywhere below it. + +import type { Context } from 'hono'; +import { streamSSE } from 'hono/streaming'; +import type { ContentfulStatusCode } from 'hono/utils/http-status'; + +import { createCandidateRegistry } from './candidates.ts'; +import type { AttemptSelector, BillableEntity, GatewayFacts } from './facts.ts'; +import type { GatewayServices, RunGatewayCtx } from './services.ts'; +import { openRunDump, type RunDump } from '../../dump/run-sink.ts'; +import { apiKeyFromContext, type AuthedContext } from '../../middleware/auth.ts'; +import { internalErrorResponse } from '../../middleware/internal-error-response.ts'; +import { backgroundSchedulerFromContext } from '../../runtime/background.ts'; +import { consoleLogSink } from '../../runtime/log.ts'; +import { stampUpstreamCallStart } from '../shared/attempt-timing.ts'; +import { createGatewayCtxFromHono, finalizeGatewayResponse, type AttemptState } from '../shared/gateway-ctx.ts'; +import { readRequestBody, takeRequestBody, type RequestBody } from '../shared/request-body.ts'; +import { writeSSEFrames } from '../shared/sse.ts'; +import { recordStream } from '@floway-dev/dump'; +import { run, getFailureFacts, type Pipeline } from '@floway-dev/pipeline'; +import { sseCommentFrame, type SseFrame } from '@floway-dev/protocols/common'; + +type Slice = { [P in K]: GatewayFacts[P] }; + +/** What the client sent, read once, before anything has decided what it means. */ +export interface Ingress { + readonly body: RequestBody; + readonly headers: readonly (readonly [string, string])[]; +} + +/** + * Reads the request. + * + * This is separate from opening the run because whether a request streams is written *in* + * the request — `stream: true` in a JSON body, a form field in a multipart upload — and the + * body can only be read once. A run opened before the read would have to guess, and guessing + * `false` is not harmless: the abort controller a streaming run cancels its upstream with is + * minted from that flag, so a client that disconnected would stop cancelling anything. + */ +export const readIngress = async (c: Context): Promise => ({ + body: await readRequestBody(c), + headers: [...c.req.raw.headers], +}); + +export interface Prologue { + readonly runDump: RunDump | null; + readonly services: GatewayServices; + readonly gateway: RunGatewayCtx; + readonly headers: readonly (readonly [string, string])[]; +} + +/** + * Opens a run: the request context the telemetry stages read, and the services the stages + * are given. It takes the bytes the handler has already read and hands them to the dump, + * which is what leaves the handler's own copy free to be released. + * + * The candidate store is the resolver the ruling names — "the resolver is the service and + * the selector is a fact". A `ModelCandidate` carries the provider's instance, its fetcher + * and its models cache; those never enter the record, because `move()` would freeze them + * and the provider's own cache refresh would break. So the stage that enumerates hands the + * live ones here, and the stage that dials asks for one back by selector. + */ +export const openPrologue = ( + c: AuthedContext, + ingress: Ingress, + options: { readonly wantsStream: boolean; readonly model?: string }, +): Prologue => { + const backgroundScheduler = backgroundSchedulerFromContext(c); + const attempt: AttemptState = { timing: { firstOutputTokenAt: null, upstreamCallStartedAt: null }, telemetry: undefined }; + // The shape follows the endpoint. A pipelined turn is recorded as its whole run — every + // stage, both directions — so it opens that recording here instead of the edge one, and + // no turn is ever written twice. + const runDump = openRunDump( + apiKeyFromContext(c), + { method: c.req.method, path: new URL(c.req.raw.url).pathname, body: ingress.body }, + backgroundScheduler, + options.wantsStream, + attempt.timing, + ); + const gateway: RunGatewayCtx = { + ...createGatewayCtxFromHono(c, { + wantsStream: options.wantsStream, + attempt, + ...(options.model === undefined ? {} : { model: options.model }), + requestBody: takeRequestBody(ingress.body), + backgroundScheduler, + dump: runDump, + }), + dump: runDump, + }; + const candidates = createCandidateRegistry(); + + return { + runDump, + gateway, + headers: ingress.headers, + services: { + gateway, + log: consoleLogSink, + background: work => { gateway.backgroundScheduler(work); }, + ...candidates, + recordProtocolFrames: frames => recordStream(frames, runDump), + httpCall: callId => { + const candidate = candidates.resolveCandidate(callId); + return { + fetcher: candidate.fetcher, + signal: candidate.model.kind === 'image' ? undefined : gateway.abortSignal, + waitUntil: gateway.backgroundScheduler, + wrapUpstreamCall: stampUpstreamCallStart(gateway.attempt.timing), + }; + }, + // Absent when this key has no retention configured, which is what keeps recording + // conditional: the runner does none of it rather than doing it and discarding. + ...(runDump === null ? {} : { dump: runDump.sink }), + + }, + }; +}; + +/** What a family writes for the client. The status and the upstream's own headers are facts + * the pipeline already carries, so what is left here is the answer itself. */ +export type Rendered = + | { + readonly body: BodyInit; + /** Owned by whichever stage serialized the body, never by the upstream — a media type + * describing bytes the gateway wrote itself is the one thing an upstream cannot say. + * `null` where a family carries an upstream's own body and the upstream declared none: + * inventing one would describe bytes nobody described. */ + readonly contentType: string | null; + } + | { + /** An answer that *is* a stream. The frames go out as they arrive, which is why nothing + * above waits for them and why what they billed is settled afterwards. */ + readonly frames: AsyncIterable; + }; + +/** Which of the two an answer turned out to be. A family's rendered fact carries whichever + * shape its run produced, and only the family knows the key it is under. */ +export const isFrames = (rendered: unknown): rendered is AsyncIterable => + typeof rendered === 'object' && rendered !== null && Symbol.asyncIterator in rendered; + +/** How a streaming turn ended, once its frames ran out. Both halves arrive together because + * both are known at the same moment — the last chunk states the usage, and running out + * without a terminal event is what "it did not finish" means. */ +export interface StreamOutcome { + readonly billable: readonly BillableEntity[]; + readonly failed: boolean; +} + +/** + * Runs a family's pipeline and turns what it answered with into a response. + * + * The drain is scheduled rather than awaited. A streaming family's answer *is* the stream, + * so draining before returning would consume the frames the client is waiting for. Release + * is not cancel — an aborted connection cannot be reused and leaves the upstream's own + * billing unsettled — so it still happens, just after the answer is on its way. + * + * A run that threw is answered here rather than by the app's own handler, because the record + * is closed here. Letting the throw out would lose the whole record: nothing above this knows + * a dump is open, so the turn that most needs explaining would be the one that left nothing + * behind. What goes to the client is the same envelope the app's handler writes, from the same + * function — the stack included, which is what a gateway owes an operator for its own fault. + */ +export const serveThrough = async < + Entry extends object, + Exit extends Slice<'response.http.status' | 'response.http.headers'>, +>( + c: Context, + prologue: Prologue, + pipeline: Pipeline, + entry: Entry, + render: (facts: Exit) => Rendered, +): Promise => { + try { + return await serveRun(c, prologue, pipeline, entry, render); + } catch (error) { + // `run` drains what it opened before it rethrows, so there is nothing left outstanding to + // release here — only the record to close, and the reason to put on it. + const failureFacts = getFailureFacts(error); + if (failureFacts !== undefined && 'route.attempt' in failureFacts) { + const attempt = failureFacts['route.attempt'] as AttemptSelector; + prologue.gateway.dump?.error('gateway', attempt.upstreamId); + } + prologue.gateway.dump?.failed(error); + return finalizeGatewayResponse(prologue.gateway, internalErrorResponse(asError(error), c)); + } +}; + +/** Anything can be thrown; only an `Error` carries a stack. A throw that was not one is + * reported as what it was rather than being dressed up as something with a call site. */ +const asError = (thrown: unknown): Error => + thrown instanceof Error ? thrown : new Error(String(thrown)); + +const serveRun = async < + Entry extends object, + Exit extends Slice<'response.http.status' | 'response.http.headers'>, +>( + c: Context, + prologue: Prologue, + pipeline: Pipeline, + entry: Entry, + render: (facts: Exit) => Rendered, +): Promise => { + const { facts, drain } = await run(pipeline, entry, prologue.services as never); + prologue.runDump?.afterRun(drain); + let answer: Rendered; + try { answer = render(facts); } catch (error) { await drain(); throw error; } + + const status = facts['response.http.status'] as ContentfulStatusCode; + if ('frames' in answer) { + // Hono's streamSSE builds the response itself, so what the client is to see has to be + // staged on the context before it is called rather than passed to a constructor. + for (const [name, value] of facts['response.http.headers']) c.header(name, value); + c.status(status); + // The dump is closed the same way on both paths. Hono builds the streaming response + // itself, so what is finalized is what it returned rather than one constructed here. + return finalizeGatewayResponse(prologue.gateway, streamSSE(c, async stream => { + try { + await writeSSEFrames(stream, answer.frames, { + keepAlive: { frame: sseCommentFrame('keepalive') }, + ...(prologue.gateway.downstreamAbortController === undefined + ? {} + : { downstreamAbortController: prologue.gateway.downstreamAbortController }), + }); + } catch (error) { + prologue.gateway.dump?.failed(error); + throw error; + } finally { + // Reading the frames to the client *is* releasing the body they came from, so the + // drain waits for that to finish. Draining alongside it would take frames out of the + // client's own stream — one connection has one reader. A client that stopped reading + // still gets here, which is what leaves nothing open behind it. + await drain(); + } + })); + } + + // Nothing is left to read either: what the client is sent was serialized from facts the run + // already held, so releasing can start at once. + prologue.services.background(drain()); + const headers = new Headers(facts['response.http.headers'].map(([name, value]): [string, string] => [name, value])); + // An upstream that declared no media type is answered without one, rather than having one + // invented for bytes nobody described. + if (answer.contentType !== null) headers.set('content-type', answer.contentType); + else headers.delete('content-type'); + return finalizeGatewayResponse(prologue.gateway, new Response(answer.body, { status, headers })); +}; diff --git a/packages/gateway/src/data-plane/pipeline/services.ts b/packages/gateway/src/data-plane/pipeline/services.ts new file mode 100644 index 0000000000..55eab63a43 --- /dev/null +++ b/packages/gateway/src/data-plane/pipeline/services.ts @@ -0,0 +1,41 @@ +// What a run is given beside its facts. Services are wiring: they are fixed for the run at +// the prologue and never change on a handoff, because what a stage hands to `next` is the +// next segment's facts — if it also supplied capabilities, the same pipeline value would +// run with different capabilities depending on who called it. +// +// Facts carry content and explicitly tagged run resources. Service handles bind application +// behavior and stay outside those values. + +import type { AttemptSelector } from './facts.ts'; +import type { RunDump } from '../../dump/run-sink.ts'; +import type { GatewayCtx } from '../shared/gateway-ctx.ts'; +import type { HttpServices } from '@floway-dev/http/pipeline'; +import type { Event, Logger } from '@floway-dev/pipeline'; +import type { ProtocolFrame } from '@floway-dev/protocols/common'; +import type { ModelCandidate } from '@floway-dev/provider'; + +export type RunGatewayCtx = Omit & { readonly dump: RunDump | null }; + +export interface GatewayServices extends HttpServices { + readonly recordProtocolFrames: >(frames: AsyncIterable) => AsyncIterable; + /** The global sink. Every stage's lines reach it, tagged with the stage's name. */ + readonly log?: Logger; + /** Present only when this request is being dumped, which is what keeps recording + * conditional: with no sink resolved here, the runner does none of it. */ + readonly dump?: (event: Event) => void | Promise; + + /** The request-scoped context the settlement and telemetry stages read. It is a service + * and not a fact because it holds live handles — the scheduler, the abort signal. */ + readonly gateway: RunGatewayCtx; + /** Turns a selector back into the thing that dials. The resolver is the service and the + * selector is the fact: a per-upstream transport is not a fact and is not pinned at the + * prologue either, so what is injected is the thing that resolves one and what travels + * is the identifier it resolves from. */ + readonly resolveAttempt: (selector: AttemptSelector) => ModelCandidate; + /** The other half of the resolver: the stage that enumerates hands the live candidates + * here, so the ones that travel can be selectors. Per run, because a candidate list is. */ + readonly rememberCandidates: (candidates: readonly ModelCandidate[]) => readonly AttemptSelector[]; + /** Binds a promise to the request's lifetime: `waitUntil` on Workers, the event loop on + * Node. What the drain is handed to, so an answer is not held up by it. */ + readonly background: (work: Promise) => void; +} diff --git a/packages/gateway/src/data-plane/pipeline/settlement.ts b/packages/gateway/src/data-plane/pipeline/settlement.ts new file mode 100644 index 0000000000..5578d198b9 --- /dev/null +++ b/packages/gateway/src/data-plane/pipeline/settlement.ts @@ -0,0 +1,112 @@ +import type { BillableEntity, GatewayFacts, AttemptSelector } from './facts.ts'; +import { providerCalls } from './provider-usage.ts'; +import type { StreamOutcome } from './serve.ts'; +import type { GatewayServices } from './services.ts'; +import type { TokenUsage, UsageQuantities } from '../../repo/types.ts'; +import { recordPerformance } from '../shared/telemetry/performance.ts'; +import { recordUsage } from '../shared/telemetry/usage.ts'; +import { defineStage, getFailureFacts, move, type Logger, type Deferred, type Facts } from '@floway-dev/pipeline'; +import type { BillingMetric } from '@floway-dev/protocols/common'; + +type Slice = { [P in K]: GatewayFacts[P] }; + +const dumpUsage = (quantities: UsageQuantities): TokenUsage => { + const count = (metric: BillingMetric): number | undefined => + quantities[metric] === undefined ? undefined : Number(quantities[metric]); + return { + ...(count('input_tokens') === undefined ? {} : { input: count('input_tokens') }), + ...(count('input_cache_read_tokens') === undefined ? {} : { input_cache_read: count('input_cache_read_tokens') }), + ...(count('input_cache_write_tokens') === undefined ? {} : { input_cache_write: count('input_cache_write_tokens') }), + ...(count('input_cache_write_1h_tokens') === undefined ? {} : { input_cache_write_1h: count('input_cache_write_1h_tokens') }), + ...(count('input_image_tokens') === undefined ? {} : { input_image: count('input_image_tokens') }), + ...(count('output_tokens') === undefined ? {} : { output: count('output_tokens') }), + ...(count('output_image_tokens') === undefined ? {} : { output_image: count('output_image_tokens') }), + }; +}; + +/** Usage writes are bound to the request lifetime without delaying an upstream response. */ +export const settleBillable = ( + services: Pick & { readonly log: Logger }, + billable: readonly BillableEntity[], + failed: boolean, + finishedAt: number = performance.now(), +): void => { + for (const entity of billable) { + services.gateway.dump?.success(entity.identity, dumpUsage(entity.quantities)); + services.background(recordUsage( + services.gateway.apiKeyId, + entity.identity, + entity.quantities, + entity.pricingFacts ?? {}, + )); + } + if (failed) services.gateway.dump?.failed('The upstream response failed.', { fallback: true }); + const outputTokens = billable.reduce((total, entity) => total + Number(entity.quantities.output_tokens ?? 0), 0); + recordPerformance(services.gateway, services.gateway.attempt.telemetry, failed, outputTokens, finishedAt); +}; + +/** Settles a run once at its exit, including calls completed before a later stage throws. */ +export const writeSettlement = ( + failed: (handedUp: Record) => boolean, + pendingUsage?: string, + recoverCalls = true, +) => defineStage< + Record, + Slice<'serve.usage.prior'>, + Slice<'response.usage.billable' | 'serve.usage.prior'>, + Slice<'response.usage.billable'>, + GatewayServices +>({ + name: 'writeSettlement', + through: { + request: { needs: [], consumes: [], provides: ['serve.usage.prior'] }, + response: { needs: ['response.usage.billable'], consumes: ['serve.usage.prior'], provides: [] }, + }, + execute: async (facts, next, use) => { + const entry = { ...facts, 'serve.usage.prior': move([]) }; + let back: Slice<'response.usage.billable' | 'serve.usage.prior'>; + try { + back = await next(entry); + } catch (error) { + const atFailure = getFailureFacts(error) ?? entry; + settleExit(use, atFailure, true, pendingUsage, true, recoverCalls); + throw error; + } + settleExit(use, back, failed(back as Facts), pendingUsage, false, recoverCalls); + const { 'serve.usage.prior': _prior, ...rest } = back as Slice<'response.usage.billable' | 'serve.usage.prior'>; + return rest; + }, +}); + +const settleExit = ( + use: GatewayServices & { readonly log: Logger }, + facts: Facts, + failed: boolean, + pendingUsage: string | undefined, + exceptional: boolean, + recoverCalls: boolean, +): void => { + const prior = exceptional ? facts['serve.usage.prior'] as readonly BillableEntity[] : []; + const pending = pendingUsage === undefined || !(pendingUsage in facts) + ? null : facts[pendingUsage] as Deferred | null; + if (pending !== null) { + use.background(pending.then(outcome => { + settleBillable(use, [...prior, ...outcome.billable], failed || outcome.failed); + }, error => { + use.gateway.dump?.failed(error); + settleBillable(use, [...prior, ...facts['response.usage.billable'] as readonly BillableEntity[]], true); + throw error; + })); + return; + } + if ('response.usage.billable' in facts) { + settleBillable(use, [...prior, ...facts['response.usage.billable'] as readonly BillableEntity[]], failed); + return; + } + if (!recoverCalls || !('response.provider.called' in facts)) { + settleBillable(use, prior, failed); + return; + } + const candidate = use.resolveAttempt(facts['route.attempt'] as AttemptSelector); + settleBillable(use, [...prior, ...providerCalls(candidate, facts)], failed); +}; diff --git a/packages/gateway/src/data-plane/pipeline/stage-contracts.ts b/packages/gateway/src/data-plane/pipeline/stage-contracts.ts new file mode 100644 index 0000000000..74e6a1923c --- /dev/null +++ b/packages/gateway/src/data-plane/pipeline/stage-contracts.ts @@ -0,0 +1,3 @@ +import type { GatewayFacts } from './facts.ts'; + +export type Slice = { [P in K]: GatewayFacts[P] }; diff --git a/packages/gateway/src/data-plane/pipeline/upstream-body.ts b/packages/gateway/src/data-plane/pipeline/upstream-body.ts new file mode 100644 index 0000000000..2e12edb8df --- /dev/null +++ b/packages/gateway/src/data-plane/pipeline/upstream-body.ts @@ -0,0 +1,66 @@ +// Reading what an upstream answered with, for the families whose protocol is JSON. +// +// Every protocol the gateway carries is one it fully understands, so a body it cannot read +// is not handed on unread: the family synthesizes an error from what it did see. That is +// also why the text is kept alongside the parse — a dump reader is owed what actually came +// back, and a message that quotes the body is the only thing a client can act on when the +// upstream answered with something the protocol does not admit. + +import type { Failure } from './facts.ts'; +import { setRelease, type Owned } from '@floway-dev/pipeline'; + +export interface UpstreamBody { + readonly text: string; + /** Absent when the body was not JSON at all, which is itself the answer to a protocol that + * requires JSON. */ + readonly json?: unknown; +} + +/** `parse` is the protocol's own JSON reader, for a protocol whose numbers must keep their + * source tokens through a round trip. */ +export const readUpstreamBody = async (response: Response, parse: (text: string) => unknown = JSON.parse): Promise => { + const text = await response.text(); + try { + return { text, json: parse(text) }; + } catch { + return { text }; + } +}; + +/** + * What an upstream that answered in something other than this protocol is worth to a client. + * + * A gateway that cannot read the answer has not served the request, whatever status came with + * it, so this is 502 rather than the upstream's own — and it is a value, because the fork + * above has another candidate to try and an answer nobody can read is exactly the outcome it + * exists to move past. + */ +export const unreadableBody = (response: Response, body: UpstreamBody, protocolName: string): Failure => ({ + status: 502, + message: `The upstream answered ${response.status} with a body ${protocolName} cannot read: ${body.text.slice(0, 200)}`, + body: 'json' in body ? body.json : body.text, +}); + +/** + * What the platform raised while dialling, as a value. + * + * Nothing in the domain throws. A connection that was refused, timed out or was reset is an + * outcome failover has to be able to see — a run that ends there has more candidates to try — + * so the ending catches whatever the platform raised and hands it up like any other failure. + * The status says what happened rather than what the error was: the gateway reached no + * upstream at all, which is what 502 states. + */ +export const dialFailure = (error: unknown): Failure => ({ + status: 502, + message: error instanceof Error ? error.message : String(error), +}); + +export const spentBody = (body: (ReadableStream & Owned) | null): typeof body => { + if (body !== null) setRelease(body, async () => {}); + return body; +}; + +/** Consumer return closes its projection; the owned body still finishes this reader at drain. */ +export const retainReader = (reader: AsyncGenerator): AsyncIterable => ({ + [Symbol.asyncIterator]: () => ({ next: () => reader.next() }), +}); diff --git a/packages/gateway/src/data-plane/rerank/attempt.ts b/packages/gateway/src/data-plane/rerank/attempt.ts deleted file mode 100644 index 1842a852dc..0000000000 --- a/packages/gateway/src/data-plane/rerank/attempt.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { Context } from 'hono'; - -import type { GatewayCtx } from '../shared/gateway-ctx.ts'; -import { inboundHeaders } from '../shared/inbound-headers.ts'; -import { telemetryModelIdentity, upstreamPerformanceContext } from '../shared/telemetry/attribution.ts'; -import { buildUpstreamCallOptions } from '../shared/upstream-call-options.ts'; -import type { RerankTarget } from '@floway-dev/protocols/common'; -import type { CanonicalRerankRequest } from '@floway-dev/protocols/rerank'; -import { providerModelOf } from '@floway-dev/provider'; -import type { ModelCandidate, PerformanceTelemetryContext, ProviderRerankCallResult, TelemetryModelIdentity } from '@floway-dev/provider'; - -export interface RerankAttemptResult { - readonly type: 'plain'; - readonly status: number; - readonly response: Response; - readonly target: RerankTarget; - readonly performance: PerformanceTelemetryContext; - readonly identity: TelemetryModelIdentity; -} - -export const rerankAttempt = async ( - c: Context, - ctx: GatewayCtx, - candidate: ModelCandidate, - request: CanonicalRerankRequest, -): Promise => { - const model = providerModelOf(candidate); - const result: ProviderRerankCallResult = await candidate.provider.instance.callRerank( - model, - request, - ctx.abortSignal, - buildUpstreamCallOptions(candidate, ctx, inboundHeaders(c)), - ); - return { - type: 'plain', - status: result.response.status, - response: result.response, - target: result.target, - performance: upstreamPerformanceContext(ctx, candidate, 'rerank'), - identity: telemetryModelIdentity(candidate, result.modelKey), - }; -}; diff --git a/packages/gateway/src/data-plane/rerank/call-upstream.ts b/packages/gateway/src/data-plane/rerank/call-upstream.ts new file mode 100644 index 0000000000..de9a1c3933 --- /dev/null +++ b/packages/gateway/src/data-plane/rerank/call-upstream.ts @@ -0,0 +1,158 @@ +import type { Fields } from './facts.ts'; +import type { UsageQuantities } from '../../repo/types.ts'; +import type { BillableEntity } from '../pipeline/facts.ts'; +import { providerEntry } from '../pipeline/provider-entry.ts'; +import { providerUsage } from '../pipeline/provider-usage.ts'; +import type { GatewayServices } from '../pipeline/services.ts'; +import { dialFailure, readUpstreamBody, unreadableBody, spentBody } from '../pipeline/upstream-body.ts'; +import { upstreamPerformanceContext, telemetryModelIdentity } from '../shared/telemetry/attribution.ts'; +import { exchangeResponse } from '@floway-dev/http/pipeline'; +import { defineStage, move } from '@floway-dev/pipeline'; +import { upstreamErrorMessage, parseDecimalString } from '@floway-dev/protocols/common'; +import { parseRerankUsage, parseRerankResponse, type CanonicalRerankResponse } from '@floway-dev/protocols/rerank'; +import { providerModelOf } from '@floway-dev/provider'; +import type { ProviderRequest, ProviderOperationPayloads, ProviderRerankResponse } from '@floway-dev/provider'; + +/** + * The ending. It dials, reads the upstream's body, and provides the canonical answer and + * what the call is billable for. A failure is a value: a 429 here is what an earlier stage + * fails over, and even a 400 can be, because the next candidate's path and flags may differ. + */ +export const callRerankUpstream = defineStage< + Fields<'request.rerank.canonical' | 'route.attempt' | 'ingress.http.headers' | 'ingress.rerank.sourceProtocol'>, + ProviderRequest, + ProviderRerankResponse, + Fields<'response.rerank.canonical' | 'response.rerank.targetProtocol' | 'response.http.status' | 'response.http.headers' | 'response.http.body' | 'response.usage.billable'>, + GatewayServices +>({ + name: 'callRerankUpstream', + into: { + request: { needs: ['request.rerank.canonical', 'route.attempt', 'ingress.http.headers', 'ingress.rerank.sourceProtocol'], consumes: [], provides: ['request.provider.model', 'request.provider.payload', 'request.http.callId', 'request.http.headers'] }, + response: { needs: ['response.http.exchange', 'response.provider.modelKey', 'response.provider.called', 'response.provider.previousCalls', 'response.http.body', 'response.provider.rerankTarget'], consumes: ['response.http.exchange', 'response.provider.modelKey', 'response.provider.called', 'response.provider.previousCalls', 'response.provider.previousCalls', 'response.provider.rerankTarget'], provides: ['response.rerank.canonical', 'response.rerank.targetProtocol', 'response.http.status', 'response.http.headers', 'response.http.body', 'response.usage.billable'] }, + }, + execute: async (facts, next, use) => { + const candidate = use.resolveAttempt(facts['route.attempt']); + const request = facts['request.rerank.canonical']; + const model = providerModelOf(candidate); + // Configuration refuses a rerank model without a target, so this holds for every candidate + // the narrowing kept; saying so here is what lets a dial that never answered still name + // the protocol it spoke. + const configuredTarget = model.rerankTarget; + if (configuredTarget === undefined) { + throw new Error(`${candidate.provider.upstreamId} serves rerank for ${candidate.model.id} without a target protocol`); + } + // Attribution is set before the dial, so an attempt that never completes still names the + // candidate it was made against rather than the one tried before it. + use.gateway.attempt.telemetry = upstreamPerformanceContext(use.gateway, candidate, 'rerank'); + + const pipeline = candidate.provider.pipelines.rerank; + if (pipeline === undefined) throw new Error(`Provider ${candidate.provider.kind} has no rerank pipeline`); + const back = await next(providerEntry(facts, candidate, request), pipeline); + const { 'response.http.exchange': exchange, 'response.provider.modelKey': modelKey, 'response.provider.called': wasCalled, 'response.provider.previousCalls': _previousCalls, 'response.provider.rerankTarget': target, ...rest } = back; + if (exchange.type === 'transportFailure') { + return move({ + ...rest, + 'response.rerank.canonical': dialFailure(exchange.error), + 'response.rerank.targetProtocol': configuredTarget.protocol, + 'response.http.status': 502, + 'response.http.headers': [], + 'response.usage.billable': providerUsage(candidate, back, []), + }); + } + const result = { response: exchangeResponse(exchange), modelKey, target }; + + const identity = telemetryModelIdentity(candidate, result.modelKey); + // What came back, unfiltered: the edge is where a client's view of it is decided. + const headers = [...result.response.headers]; + spentBody(exchange.body); + const body = await readUpstreamBody(result.response); + + if (!result.response.ok) { + await use.log.warn('upstream refused', { status: result.response.status }); + return move({ + ...rest, + 'response.rerank.canonical': { + status: result.response.status, + message: upstreamErrorMessage(body.json) ?? body.text, + ...('json' in body ? { body: body.json } : {}), + }, + 'response.rerank.targetProtocol': result.target.protocol, + 'response.http.status': result.response.status, + 'response.http.headers': headers, + // The upstream was called and reported nothing, which is a different situation + // from reporting zero — so the entity is present with no quantities. + 'response.usage.billable': providerUsage(candidate, back, wasCalled ? [{ identity, quantities: {} }] : []), + }); + } + + if (!('json' in body)) { + return move({ + ...rest, + 'response.rerank.canonical': unreadableBody(result.response, body, 'the rerank protocol'), + 'response.rerank.targetProtocol': result.target.protocol, + 'response.http.status': result.response.status, + 'response.http.headers': headers, + 'response.usage.billable': providerUsage(candidate, back, wasCalled ? [{ identity, quantities: {} }] : []), + }); + } + + // A usage block the reader cannot make sense of is a report we cannot parse, which from + // here is no report. It is read before the results, so an answer this gateway could not + // model still bills for what the upstream did meter — the two readings are independent + // and one of them failing is not a reason to discard the other. + let usage: Pick; + try { + usage = parseRerankUsage(result.target.protocol, body.json); + } catch (error) { + await use.log.warn('upstream reported usage the rerank protocol cannot read', { error: String(error) }); + usage = {}; + } + const metered: readonly BillableEntity[] = [{ + identity, + quantities: billed(usage), + // A rerank rate can depend on how large the input was and not only on how much of it + // there was, so the token total is a pricing input as well as a quantity. + ...(usage.totalTokens === undefined ? {} : { pricingFacts: { inputTokens: usage.totalTokens } }), + }]; + + // An answer the client's own protocol produced is what that client already reads, so the + // edge renders it back out unchanged and nothing here has to model it. Reading the results + // is what a *translation* needs, and only a cross-protocol run does one — so a result item + // this gateway cannot model is a failure there and a field it simply carries here. + const translating = facts['ingress.rerank.sourceProtocol'] !== result.target.protocol; + let canonical: CanonicalRerankResponse; + try { + canonical = parseRerankResponse(result.target.protocol, body.json); + } catch (error) { + if (translating) { + await use.log.warn('upstream answered with results the rerank protocol cannot read', { error: String(error) }); + return move({ + ...rest, + 'response.rerank.canonical': unreadableBody(result.response, body, 'the rerank protocol'), + 'response.rerank.targetProtocol': result.target.protocol, + 'response.http.status': result.response.status, + 'response.http.headers': headers, + 'response.usage.billable': providerUsage(candidate, back, wasCalled ? metered : []), + }); + } + await use.log.debug('same-protocol answer carries results this gateway does not model', { error: String(error) }); + canonical = { raw: body.json as Record, results: [] }; + } + + return move({ + ...rest, + 'response.rerank.canonical': canonical, + 'response.rerank.targetProtocol': result.target.protocol, + 'response.http.status': result.response.status, + 'response.http.headers': headers, + 'response.usage.billable': providerUsage(candidate, back, wasCalled ? metered : []), + }); + }, +}); + +const billed = (usage: Pick | undefined): UsageQuantities => { + const quantities: UsageQuantities = {}; + if (usage?.searchUnits !== undefined) quantities.rerank_searches = parseDecimalString(String(usage.searchUnits)); + if (usage?.totalTokens !== undefined) quantities.input_tokens = parseDecimalString(String(usage.totalTokens)); + return quantities; +}; diff --git a/packages/gateway/src/data-plane/rerank/emit.ts b/packages/gateway/src/data-plane/rerank/emit.ts new file mode 100644 index 0000000000..0a1a60029b --- /dev/null +++ b/packages/gateway/src/data-plane/rerank/emit.ts @@ -0,0 +1,78 @@ +import type { Fields } from './facts.ts'; +import { isFailure } from '../pipeline/facts.ts'; +import { isForwardableUpstreamHeader } from '../shared/upstream-response.ts'; +import { defineStage, move } from '@floway-dev/pipeline'; +import { renderErrorEnvelope } from '@floway-dev/protocols/common'; +import { renderRerankResponse } from '@floway-dev/protocols/rerank'; + +/** + * The outermost edge. Renders the canonical answer into the protocol the client spoke — + * which is why `ingress.rerank.sourceProtocol` is an ingress fact and not a request one: + * it survives the switch to whatever protocol the upstream turned out to speak. + */ +export const emitRerank = defineStage< + Fields<'ingress.rerank.sourceProtocol' | 'request.rerank.canonical'>, + Fields<'ingress.rerank.sourceProtocol' | 'request.rerank.canonical'>, + Fields<'ingress.rerank.sourceProtocol' | 'request.rerank.canonical' | 'response.rerank.canonical' | 'response.rerank.targetProtocol' | 'response.http.status' | 'response.http.headers'>, + Fields<'response.rerank.rendered' | 'response.http.status' | 'response.http.headers'> +>({ + name: 'emitRerank', + through: { + request: { + needs: ['ingress.rerank.sourceProtocol', 'request.rerank.canonical'], + consumes: [], + provides: [], + }, + response: { + needs: ['response.rerank.canonical', 'response.http.headers', 'response.http.status'], + consumes: ['response.rerank.canonical', 'response.rerank.targetProtocol', 'response.http.headers'], + provides: ['response.rerank.rendered', 'response.http.status', 'response.http.headers'], + }, + }, + execute: async (facts, next) => { + const back = await next(facts); + const { 'response.rerank.canonical': answer, 'response.rerank.targetProtocol': target, 'response.http.headers': headers, ...rest } = back; + // Vendor traces and quota state stay visible; what an intermediary must strip, and what + // would misdescribe a body this gateway serialized itself, does not. A filter that removed + // nothing hands the same array on, so the record shows no change where none happened. + const forwardable = headers.filter(([name]) => isForwardableUpstreamHeader(name)); + const forClient = forwardable.length === headers.length ? headers : move(forwardable); + if (isFailure(answer)) { + return { + ...rest, + 'response.http.headers': forClient, + 'response.rerank.rendered': move(renderErrorEnvelope(answer.message, answer.body)), + // The upstream's own status, or the gateway's own when it refused before dialling. + // A client is not owed the upstream's exact bytes; it is owed the truth about what + // happened, and a 429 arriving as a 200 is not that. + 'response.http.status': answer.status, + }; + } + // Translating can fail on an answer that parsed: a result may index a document the + // request never sent. The upstream answered and the gateway cannot put that answer in the + // protocol the client speaks, which is the gateway failing to serve rather than anything + // the client can fix — so it is 502, and a value like every other failure here. + let rendered: Record; + try { + rendered = renderRerankResponse( + back['ingress.rerank.sourceProtocol'], + target, + answer, + back['request.rerank.canonical'], + ); + } catch (error) { + return { + ...rest, + 'response.http.headers': forClient, + 'response.rerank.rendered': move(renderErrorEnvelope(error instanceof Error ? error.message : String(error))), + 'response.http.status': 502, + }; + } + return { + ...rest, + 'response.http.headers': forClient, + 'response.http.status': back['response.http.status'], + 'response.rerank.rendered': move(rendered), + }; + }, +}); diff --git a/packages/gateway/src/data-plane/rerank/facts.ts b/packages/gateway/src/data-plane/rerank/facts.ts new file mode 100644 index 0000000000..ae73bab404 --- /dev/null +++ b/packages/gateway/src/data-plane/rerank/facts.ts @@ -0,0 +1,22 @@ +import type { GatewayFacts, Failure } from '../pipeline/facts.ts'; +import type { RerankSourceProtocol, RerankProtocol } from '@floway-dev/protocols/common'; +import type { CanonicalRerankRequest, CanonicalRerankResponse } from '@floway-dev/protocols/rerank'; + +/** Rerank's own keys. They extend the shared space and never merge into it, so a stage + * written against the gateway alone cannot name one. */ +export interface RerankFacts extends GatewayFacts { + /** Which of the four rerank protocols the client spoke. It belongs to the ingress and + * stays put: the answer is rendered back into it whatever the upstream spoke. */ + 'ingress.rerank.sourceProtocol': RerankSourceProtocol; + 'request.rerank.canonical': CanonicalRerankRequest; + 'response.rerank.canonical': CanonicalRerankResponse | Failure; + /** Which protocol the attempt spoke. A response fact, because an upstream picks the target + * it answers in and the edge needs it to render back into the client's — a run that never + * reached one carries the target its request was serialized for. */ + 'response.rerank.targetProtocol': RerankProtocol; + /** What the client is actually sent, in its own protocol. The edge provides it, so a + * dump shows the bytes the client received rather than the gateway's canonical form. */ + 'response.rerank.rendered': Record; +} + +export type Fields = { [P in K]: RerankFacts[P] }; diff --git a/packages/gateway/src/data-plane/rerank/pipeline.ts b/packages/gateway/src/data-plane/rerank/pipeline.ts new file mode 100644 index 0000000000..71a22324af --- /dev/null +++ b/packages/gateway/src/data-plane/rerank/pipeline.ts @@ -0,0 +1,26 @@ +import { callRerankUpstream } from './call-upstream.ts'; +import { emitRerank } from './emit.ts'; +import type { Fields } from './facts.ts'; +import { narrowing } from './target.ts'; +import { isFailure } from '../pipeline/facts.ts'; +import { failover } from '../pipeline/failover.ts'; +import { resolveCandidates } from '../pipeline/resolve-candidates.ts'; +import { serializeClientJson } from '../pipeline/serialize-client-json.ts'; +import { writeSettlement } from '../pipeline/settlement.ts'; +import { compose, type Pipeline } from '@floway-dev/pipeline'; +import type { CanonicalRerankRequest } from '@floway-dev/protocols/rerank'; + +export const rerankServePipeline = (request: CanonicalRerankRequest): Pipeline< + Fields<'ingress.http.headers' | 'ingress.rerank.sourceProtocol' | 'request.rerank.canonical' | 'serve.model'>, + Fields<'response.http.jsonBody' | 'response.rerank.rendered' | 'response.http.status' | 'response.http.headers' | 'response.usage.billable'> +> => compose('rerankServe', [ + writeSettlement(handedUp => Number(handedUp['response.http.status']) >= 400), + serializeClientJson('response.rerank.rendered'), + emitRerank, + resolveCandidates(narrowing(request)), + failover({ + failed: handedUp => isFailure((handedUp as { 'response.rerank.canonical'?: unknown })['response.rerank.canonical']), + owns: ['response.http.body'], + }), + callRerankUpstream, +]); diff --git a/packages/gateway/src/data-plane/rerank/serve.ts b/packages/gateway/src/data-plane/rerank/serve.ts index 610e371e3c..1744d30216 100644 --- a/packages/gateway/src/data-plane/rerank/serve.ts +++ b/packages/gateway/src/data-plane/rerank/serve.ts @@ -1,185 +1,67 @@ -import type { Context } from 'hono'; -import type { ContentfulStatusCode } from 'hono/utils/http-status'; +// POST /v1/rerank, /v2/rerank, /jina/v1/rerank and /voyage/v1/rerank, served through the +// pipeline. +// +// One family behind four routes. Which protocol the client spoke is not in the body — the +// route it arrived on is the whole of that statement — so the mount passes it in and the +// handler carries it into the record as an ingress fact. Everything after the parse is +// stages. -import { rerankAttempt, type RerankAttemptResult } from './attempt.ts'; -import type { UsageQuantities } from '../../repo/types.ts'; -import { backgroundSchedulerFromContext } from '../../runtime/background.ts'; -import { enumerateModelCandidates } from '../providers/resolution.ts'; -import { appendFailedUpstreams } from '../shared/failed-upstreams.ts'; -import { createGatewayCtxFromHono, finalizeGatewayResponse, type GatewayCtx } from '../shared/gateway-ctx.ts'; -import { iterateCandidates } from '../shared/iterate-candidates.ts'; -import { readRequestBody, takeRequestBody } from '../shared/request-body.ts'; -import { recordFailedRequest, recordPerformance, type PerformanceTelemetryContext } from '../shared/telemetry/performance.ts'; -import { recordUsage } from '../shared/telemetry/usage.ts'; -import { forwardUpstreamResponse } from '../shared/upstream-response.ts'; -import { parseDecimalString, type RerankSourceProtocol } from '@floway-dev/protocols/common'; -import { parseRerankRequest, parseRerankResponse, parseRerankUsage, renderRerankResponse, rerankRequestIncompatibility, type CanonicalRerankResponse, type ParsedRerankRequest } from '@floway-dev/protocols/rerank'; -import { httpResponseToResponse, ProviderModelsUnavailableError, providerModelOf, toInternalDebugError } from '@floway-dev/provider'; -import type { TelemetryModelIdentity } from '@floway-dev/provider'; +import type { Context } from 'hono'; -const apiError = (c: Context, message: string, status: ContentfulStatusCode): Response => - c.json({ error: { message, type: 'api_error' } }, status); +import { rerankServePipeline } from './pipeline.ts'; +import { openPrologue, readIngress, serveThrough } from '../pipeline/serve.ts'; +import { finalizeGatewayResponse } from '../shared/gateway-ctx.ts'; +import { move } from '@floway-dev/pipeline'; +import type { RerankSourceProtocol } from '@floway-dev/protocols/common'; +import { parseRerankRequest, type ParsedRerankRequest } from '@floway-dev/protocols/rerank'; -const parseJson = (bytes: Uint8Array): unknown => { +// The contract reports a malformed request by throwing; what the client is owed is a 400 +// carrying the reason. `JSON.parse`'s own wording names a byte offset in a body the client +// already has, so the reason for that one is written here instead. +const readRequest = ( + sourceProtocol: RerankSourceProtocol, + bytes: Uint8Array, +): { type: 'ok'; parsed: ParsedRerankRequest } | { type: 'invalid'; message: string } => { + let body: unknown; try { - return JSON.parse(new TextDecoder().decode(bytes)) as unknown; + body = JSON.parse(new TextDecoder().decode(bytes)) as unknown; } catch { - throw new Error('Rerank request body must be valid JSON'); + return { type: 'invalid', message: 'Rerank request body must be valid JSON' }; } -}; - -const settleRerank = ( - ctx: GatewayCtx, - performanceContext: PerformanceTelemetryContext, - identity: TelemetryModelIdentity, - usage: Pick | undefined, - failed: boolean, -): void => { - const quantities: UsageQuantities = {}; - if (usage?.searchUnits !== undefined) quantities.rerank_searches = parseDecimalString(String(usage.searchUnits)); - if (usage?.totalTokens !== undefined) quantities.input_tokens = parseDecimalString(String(usage.totalTokens)); - const pricingFacts = usage?.totalTokens === undefined ? {} : { inputTokens: usage.totalTokens }; - ctx.backgroundScheduler(recordUsage(ctx.apiKeyId, identity, quantities, pricingFacts).catch(error => { - console.error('Failed to record rerank usage:', error); - })); - recordPerformance(ctx, performanceContext, failed, 0, performance.now()); -}; - -const unsupportedMessage = (model: string): string => `Model ${model} does not support rerank.`; - -export const rerank = (sourceProtocol: RerankSourceProtocol) => async (c: Context): Promise => { - const requestBody = await readRequestBody(c); - let parsedRequest: ParsedRerankRequest; try { - parsedRequest = parseRerankRequest(sourceProtocol, parseJson(requestBody.bytes)); + return { type: 'ok', parsed: parseRerankRequest(sourceProtocol, body) }; } catch (error) { - const ctx = createGatewayCtxFromHono(c, { - wantsStream: false, - requestBody: takeRequestBody(requestBody), - backgroundScheduler: backgroundSchedulerFromContext(c), - }); - ctx.dump?.error('gateway'); - return finalizeGatewayResponse(ctx, apiError(c, error instanceof Error ? error.message : String(error), 400)); + return { type: 'invalid', message: error instanceof Error ? error.message : String(error) }; } +}; - const { model, request } = parsedRequest; - const ctx = createGatewayCtxFromHono(c, { - wantsStream: false, - model, - requestBody: takeRequestBody(requestBody), - backgroundScheduler: backgroundSchedulerFromContext(c), - }); - - let terminal: RerankAttemptResult | undefined; - let measuredUsage: Pick | undefined; - let usageSettled = false; - try { - const { candidates, sawModel, failedUpstreams } = await enumerateModelCandidates({ - subject: ctx.user, - upstreamIds: ctx.upstreamIds, - randomizeUpstreams: ctx.randomizeUpstreams, - model, - kind: 'rerank', - scheduler: ctx.backgroundScheduler, - runtimeLocation: ctx.runtimeLocation, - }); - if (candidates.length === 0) { - ctx.dump?.error('gateway'); - const message = sawModel - ? unsupportedMessage(model) - : `Model ${model} is not available on any configured upstream.`; - return finalizeGatewayResponse(ctx, apiError(c, appendFailedUpstreams(message, failedUpstreams), sawModel ? 400 : 404)); - } - - const routable = candidates.flatMap(candidate => { - const providerModel = providerModelOf(candidate); - return candidate.model.endpoints.rerank === undefined || providerModel.rerankTarget === undefined - ? [] - : [{ candidate, target: providerModel.rerankTarget }]; - }); - if (routable.length === 0) { - ctx.dump?.error('gateway'); - return finalizeGatewayResponse(ctx, apiError(c, appendFailedUpstreams(unsupportedMessage(model), failedUpstreams), 400)); - } - const viable = routable.filter(({ target }) => rerankRequestIncompatibility(target.protocol, request) === null); - if (viable.length === 0) { - const reasons = [...new Set(routable.flatMap(({ target }) => { - const reason = rerankRequestIncompatibility(target.protocol, request); - return reason === null ? [] : [reason]; - }))]; - ctx.dump?.error('gateway'); - return finalizeGatewayResponse(ctx, apiError(c, `Model ${model} does not support this rerank request: ${reasons.join('; ')}.`, 400)); - } - - terminal = await iterateCandidates( - viable.map(({ candidate }) => candidate), - 'rerank', - ctx, - 'rerank', - candidate => rerankAttempt(c, ctx, candidate, request), +export const rerank = (sourceProtocol: RerankSourceProtocol) => async (c: Context): Promise => { + const ingress = await readIngress(c); + const result = readRequest(sourceProtocol, ingress.body.bytes); + if (result.type === 'invalid') { + // A request the gateway could not read never reaches a pipeline: there is no model to + // resolve and no attempt to make, so there is nothing for a run to record. + const refused = openPrologue(c, ingress, { wantsStream: false }); + refused.gateway.dump?.error('gateway'); + return finalizeGatewayResponse( + refused.gateway, + Response.json({ error: { message: result.message, type: 'api_error' } }, { status: 400 }), ); + } - if (!terminal.response.ok) { - ctx.dump?.error('upstream', terminal.identity.upstream); - settleRerank(ctx, terminal.performance, terminal.identity, undefined, true); - usageSettled = true; - return finalizeGatewayResponse(ctx, forwardUpstreamResponse(terminal.response)); - } + const { model, request } = result.parsed; + const prologue = openPrologue(c, ingress, { wantsStream: false, model }); - const sameProtocol = sourceProtocol === terminal.target.protocol; - let upstreamBody: unknown; - try { - upstreamBody = await terminal.response.clone().json() as unknown; - } catch (error) { - if (!sameProtocol) throw error; - console.warn( - `rerank: failed to parse same-protocol 2xx upstream body for ${sourceProtocol}; usage row will be request-only`, - error instanceof Error ? error.message : String(error), - ); - ctx.dump?.success(terminal.identity, null); - settleRerank(ctx, terminal.performance, terminal.identity, undefined, false); - usageSettled = true; - return finalizeGatewayResponse(ctx, forwardUpstreamResponse(terminal.response)); - } - try { - measuredUsage = parseRerankUsage(terminal.target.protocol, upstreamBody); - } catch (error) { - if (!sameProtocol) throw error; - console.warn( - `rerank: failed to parse same-protocol usage for ${sourceProtocol}; usage row will be request-only`, - error instanceof Error ? error.message : String(error), - ); - ctx.dump?.success(terminal.identity, null); - settleRerank(ctx, terminal.performance, terminal.identity, undefined, false); - usageSettled = true; - return finalizeGatewayResponse(ctx, forwardUpstreamResponse(terminal.response)); - } - if (sameProtocol) { - ctx.dump?.success(terminal.identity, null); - settleRerank(ctx, terminal.performance, terminal.identity, measuredUsage, false); - usageSettled = true; - return finalizeGatewayResponse(ctx, forwardUpstreamResponse(terminal.response)); - } - const canonical = parseRerankResponse(terminal.target.protocol, upstreamBody); - const rendered = renderRerankResponse(sourceProtocol, terminal.target.protocol, canonical, request); - ctx.dump?.success(terminal.identity, null); - settleRerank(ctx, terminal.performance, terminal.identity, measuredUsage, false); - usageSettled = true; - return finalizeGatewayResponse(ctx, forwardUpstreamResponse(terminal.response, { body: JSON.stringify(rendered) })); - } catch (error) { - if (terminal !== undefined && !usageSettled) { - settleRerank(ctx, terminal.performance, terminal.identity, measuredUsage, true); - } else if (terminal === undefined) { - recordFailedRequest(ctx, ctx.attempt.telemetry); - } - if (error instanceof ProviderModelsUnavailableError) { - const forwarded = httpResponseToResponse(error.httpResponse); - if (forwarded) { - ctx.dump?.error('upstream'); - return finalizeGatewayResponse(ctx, forwarded); - } - } - ctx.dump?.failed(error); - return finalizeGatewayResponse(ctx, c.json({ error: toInternalDebugError(error) }, 502)); - } + return await serveThrough( + c, + prologue, + rerankServePipeline(request), + move({ + 'ingress.http.headers': prologue.headers, + 'ingress.rerank.sourceProtocol': sourceProtocol, + 'request.rerank.canonical': request, + 'serve.model': model, + }) as never, + facts => ({ body: facts['response.http.jsonBody']!, contentType: 'application/json' }), + ); }; diff --git a/packages/gateway/src/data-plane/rerank/target.ts b/packages/gateway/src/data-plane/rerank/target.ts new file mode 100644 index 0000000000..3246ad0d99 --- /dev/null +++ b/packages/gateway/src/data-plane/rerank/target.ts @@ -0,0 +1,20 @@ +import { rerankRequestIncompatibility, type CanonicalRerankRequest } from '@floway-dev/protocols/rerank'; +import { providerModelOf } from '@floway-dev/provider'; + +/** A candidate that cannot serve *this* request is not a candidate. Saying why is what + * turns an empty list into a 400 a client can act on. */ +export const narrowing = (request: CanonicalRerankRequest) => ({ + kind: 'rerank' as const, + reject: (candidate: Parameters[0]) => { + const model = providerModelOf(candidate); + if (candidate.model.endpoints.rerank === undefined || model.rerankTarget === undefined) { + return 'the upstream does not expose a rerank endpoint'; + } + return rerankRequestIncompatibility(model.rerankTarget.protocol, request); + }, + unsupported: (model: string, reasons: readonly string[]) => reasons.length === 0 + ? `Model ${model} does not support rerank.` + : `Model ${model} does not support this rerank request: ${reasons.join('; ')}.`, + refuse: (status: number, message: string) => ({ 'response.rerank.canonical': { status, message } }), + refuses: ['response.rerank.canonical'] as const, +}); diff --git a/packages/gateway/src/data-plane/routes.ts b/packages/gateway/src/data-plane/routes.ts index 84fc0ffb0f..fd6a7c0ab2 100644 --- a/packages/gateway/src/data-plane/routes.ts +++ b/packages/gateway/src/data-plane/routes.ts @@ -3,7 +3,7 @@ import type { Hono } from 'hono'; import { mountAlphaSearchRoutes } from './alpha-search/routes.ts'; import { mountChatRoutes } from './chat/routes.ts'; import { mountCodexRoutes } from './codex/routes.ts'; -import { decision } from './decision/serve.ts'; +import { decision } from './decision/http.ts'; import { serveGeminiModelInfo } from './models/gemini.ts'; import { serveModels } from './models/http.ts'; import { openaiAudioTranscriptions } from './openai-audio/http.ts'; diff --git a/packages/gateway/src/data-plane/shared/api-names.ts b/packages/gateway/src/data-plane/shared/api-names.ts deleted file mode 100644 index 174855734f..0000000000 --- a/packages/gateway/src/data-plane/shared/api-names.ts +++ /dev/null @@ -1,11 +0,0 @@ -// Routing-side API name primitives shared across the data plane. -// -// `PassthroughServeApiName` is the set of API names served by the -// passthroughServe helper (see ./passthrough-serve.ts) rather than the -// chat source/target executor. It groups by transport shape (the body / -// frames are forwarded verbatim, possibly with a usage-extraction step), -// not by whether the endpoint is chat-shaped — `/completions` is a -// non-chat endpoint that lives here because there is nothing to translate -// to or from. The value is the public URL fragment, so it can be used -// directly in error messages and route comparisons without a lookup table. -export type PassthroughServeApiName = '/completions' | '/embeddings' | '/images/generations' | '/images/edits' | '/audio/transcriptions'; diff --git a/packages/gateway/src/data-plane/shared/gateway-ctx.ts b/packages/gateway/src/data-plane/shared/gateway-ctx.ts index fc81272f0c..13afb786b9 100644 --- a/packages/gateway/src/data-plane/shared/gateway-ctx.ts +++ b/packages/gateway/src/data-plane/shared/gateway-ctx.ts @@ -1,6 +1,7 @@ import type { AttemptTiming } from './attempt-timing.ts'; import type { RequestBody } from './request-body.ts'; -import { type DumpAccumulator, openDumpAccumulator } from '../../dump/accumulator.ts'; +import { openDumpAccumulator } from '../../dump/accumulator.ts'; +import type { TurnDump } from '../../dump/turn-dump.ts'; import { apiKeyFromContext, type AuthedContext, effectiveUpstreamIdsFromContext, userFromContext } from '../../middleware/auth.ts'; import type { User } from '../../repo/types.ts'; import { getRuntimeLocation } from '../../runtime/runtime-info.ts'; @@ -34,11 +35,16 @@ export interface GatewayCtx { // Null when the api key has no dump retention configured, in which case // `finalizeGatewayResponse` short-circuits the dump tee and returns the // response untouched. - readonly dump: DumpAccumulator | null; + readonly dump: TurnDump | null; } export interface CreateGatewayCtxOptions { wantsStream: boolean; + attempt?: AttemptState; + // What this turn is recorded as. The shape follows the endpoint: a pipelined one hands in + // its run recording, and everything else lets the factory open the edge accumulator. + // Absent and null differ — absent means "open the usual one", null means "record nothing". + dump?: TurnDump | null; // WebSocket-style call sites own the AbortController (so the upgrade // handler can cancel mid-stream); HTTP call sites let the factory mint one // when wantsStream is true. @@ -74,8 +80,10 @@ export const createGatewayCtxFromHono = (c: AuthedContext, opts: CreateGatewayCt const controller = opts.downstreamAbortController ?? (opts.wantsStream ? new AbortController() : undefined); const apiKey = apiKeyFromContext(c); const upstreamIds = effectiveUpstreamIdsFromContext(c); - const attempt: AttemptState = { timing: { firstOutputTokenAt: null, upstreamCallStartedAt: null }, telemetry: undefined }; - const dump = openDumpAccumulator(c, opts.method ?? c.req.method, apiKey, opts.requestBody, opts.backgroundScheduler, opts.wantsStream, attempt.timing); + const attempt: AttemptState = opts.attempt ?? { timing: { firstOutputTokenAt: null, upstreamCallStartedAt: null }, telemetry: undefined }; + const dump = 'dump' in opts + ? opts.dump ?? null + : openDumpAccumulator(c, opts.method ?? c.req.method, apiKey, opts.requestBody, opts.backgroundScheduler, opts.wantsStream, attempt.timing); if (opts.model !== undefined) dump?.requestedModel(opts.model); return { apiKeyId: apiKey.id, diff --git a/packages/gateway/src/data-plane/shared/passthrough-request.ts b/packages/gateway/src/data-plane/shared/json-model-request.ts similarity index 100% rename from packages/gateway/src/data-plane/shared/passthrough-request.ts rename to packages/gateway/src/data-plane/shared/json-model-request.ts diff --git a/packages/gateway/src/data-plane/shared/passthrough-attempt.ts b/packages/gateway/src/data-plane/shared/passthrough-attempt.ts deleted file mode 100644 index db997affe0..0000000000 --- a/packages/gateway/src/data-plane/shared/passthrough-attempt.ts +++ /dev/null @@ -1,82 +0,0 @@ -// Per-candidate passthrough attempt: does the upstream HTTP call for one -// resolved candidate and hands the serve loop back a `plain`-shaped result -// the shared `iterateCandidates` iterator can drive. -// -// Chat protocols run each attempt through a translation + interceptor -// stack that yields an `ExecuteResult` discriminated union. Passthrough -// endpoints have no translation — the request body is forwarded to the -// upstream's matching endpoint and the raw upstream Response is returned -// verbatim. The `plain` discriminant is enlarged here to carry that -// Response plus the per-call telemetry the serve site needs when it -// forwards the winning attempt (2xx) or the last failure (exhausted). - -import type { GatewayCtx } from './gateway-ctx.ts'; -import { inboundHeaders } from './inbound-headers.ts'; -import { telemetryModelIdentity, upstreamPerformanceContext } from './telemetry/attribution.ts'; -import type { PerformanceTelemetryContext } from './telemetry/performance.ts'; -import { buildUpstreamCallOptions } from './upstream-call-options.ts'; -import type { AuthedContext } from '../../middleware/auth.ts'; -import { providerModelOf } from '@floway-dev/provider'; -import type { ModelCandidate, PerformanceOperation, Provider, ProviderCallResult, ProviderModel, TelemetryModelIdentity, UpstreamCallOptions } from '@floway-dev/provider'; - -// Enlarged `plain` shape: `iterateCandidates` reads `type` + `status`; -// the passthrough serve reads the rest to forward the response and -// attribute dumps. `identity` carries the upstream id alongside the -// model/pricing metadata the dump and usage-record paths already consume -// together. -export interface PassthroughAttemptResult { - readonly type: 'plain'; - readonly status: number; - readonly response: Response; - readonly performance: PerformanceTelemetryContext; - readonly identity: TelemetryModelIdentity; -} - -export interface PassthroughAttemptArgs { - readonly c: AuthedContext; - readonly ctx: GatewayCtx; - readonly candidate: ModelCandidate; - readonly operation: PerformanceOperation; - // Delegated to the passthrough caller so each endpoint keeps its - // request-body shaping (`{ model: _, ...body }`) local. Any throw here - // is preserved and the serve layer turns it into a 502 with the - // internal-debug envelope. - readonly call: (provider: Provider, model: ProviderModel, opts: UpstreamCallOptions) => Promise; -} - -// Statuses whose response is defined to carry no body; constructing a Response -// with one throws. -// https://fetch.spec.whatwg.org/#null-body-status -const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]); - -// The fallback loop keeps only the most recent failure and drops the rest, and -// on the direct-connect egress a dropped response strands its socket — the -// transport is released only when the body is read to its end or cancelled. -// Reading a failed attempt's body now makes the result inert, so no later owner -// has to remember to release it. The bytes, status and headers still forward -// verbatim when this turns out to be the last candidate. This is what the chat -// path already does through `readUpstreamApiError`. -const materializeFailure = async (response: Response): Promise => { - const bytes = await response.arrayBuffer(); - return new Response(NULL_BODY_STATUSES.has(response.status) ? null : bytes, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }); -}; - -export const passthroughAttempt = async (args: PassthroughAttemptArgs): Promise => { - const { c, ctx, candidate, operation, call } = args; - const { response, modelKey } = await call( - candidate.provider, - providerModelOf(candidate), - buildUpstreamCallOptions(candidate, ctx, inboundHeaders(c)), - ); - return { - type: 'plain', - status: response.status, - response: response.ok ? response : await materializeFailure(response), - performance: upstreamPerformanceContext(ctx, candidate, operation), - identity: telemetryModelIdentity(candidate, modelKey), - }; -}; diff --git a/packages/gateway/src/data-plane/shared/passthrough-serve.ts b/packages/gateway/src/data-plane/shared/passthrough-serve.ts deleted file mode 100644 index 82f5ef2e62..0000000000 --- a/packages/gateway/src/data-plane/shared/passthrough-serve.ts +++ /dev/null @@ -1,258 +0,0 @@ -// Shared serve scaffold for passthrough data-plane endpoints. These -// bypass the chat source/target executor because they have no protocol -// translation — the request body is forwarded to the chosen provider's -// matching endpoint and the upstream response is passed through back to -// the client. OpenAI Embeddings and OpenAI Images run the `json` branch -// (single-shot body, OpenAI-shape `usage` block); /v1/completions runs the -// `sse` branch (frame-level transformFrame closure + settleUsage). -// Endpoint-owned response strategies handle specialized media-type state -// machines. Usage and request-performance writes are scheduled through the -// runtime's background scheduler so transient repo failures cannot turn a -// successful 200 from upstream into a 502. - -import type { Context } from 'hono'; -import { streamSSE } from 'hono/streaming'; -import type { ContentfulStatusCode } from 'hono/utils/http-status'; - -import type { PassthroughServeApiName } from './api-names.ts'; -import { appendFailedUpstreams } from './failed-upstreams.ts'; -import type { GatewayCtx } from './gateway-ctx.ts'; -import { iterateCandidates } from './iterate-candidates.ts'; -import { passthroughAttempt } from './passthrough-attempt.ts'; -import { type StreamCompletion, writeSSEFrames } from './sse.ts'; -import { recordFailedRequest } from './telemetry/performance.ts'; -import { settle } from './telemetry/settle.ts'; -import { forwardUpstreamHeaders, forwardUpstreamResponse } from './upstream-response.ts'; -import type { AuthedContext } from '../../middleware/auth.ts'; -import type { TokenUsage } from '../../repo/types.ts'; -import { enumerateModelCandidates } from '../providers/resolution.ts'; -import { doneFrame, eventFrame, type ModelKind, parseSSEStream, parseTargetStreamFrames, type ProtocolFrame, sseCommentFrame, sseFrame } from '@floway-dev/protocols/common'; -import { httpResponseToResponse, ProviderModelsUnavailableError, toInternalDebugError } from '@floway-dev/provider'; -import type { PerformanceOperation, PerformanceTelemetryContext, InternalModel, Provider, ProviderCallResult, ProviderModel, TelemetryModelIdentity, UpstreamCallOptions } from '@floway-dev/provider'; - -// `json` (OpenAI Embeddings, OpenAI Images): single-shot body, -// `extractBilling` reads usage / metadata off the parsed root. `sse` -// (/v1/completions): frame stream, `transformFrame` mutates or drops frames -// (return null), then `settleUsage` reports billing once the stream ends. -// `strategy` delegates response handling after candidate selection to the -// owning endpoint. -type PassthroughResponseHandling = - | { - readonly format: 'json'; - readonly extractBilling: (body: unknown) => TokenUsage | null; - } - | { - readonly format: 'sse'; - readonly transformFrame: (frame: ProtocolFrame) => ProtocolFrame | null; - readonly settleUsage: () => TokenUsage | null; - } - | { - readonly format: 'strategy'; - readonly respond: (context: PassthroughResponseStrategyContext) => Promise; - }; - -export interface PassthroughResponseStrategyContext { - readonly c: AuthedContext; - readonly ctx: GatewayCtx; - readonly sourceApi: PassthroughServeApiName; - readonly response: Response; - readonly performance: PerformanceTelemetryContext; - readonly identity: TelemetryModelIdentity; -} - -interface PassthroughServeContext { - readonly c: AuthedContext; - readonly ctx: GatewayCtx; - readonly sourceApi: PassthroughServeApiName; - readonly operation: PerformanceOperation; - // Already-validated public model id the client requested. The helper - // resolves it against the provider registry; if no upstream serves the - // id with the requested kind, the client sees a 404 with the standard - // wording. - readonly model: string; - // The model kind this endpoint serves. The resolver filters candidates - // to `model.kind === kind`; `sawModel=true && candidates=[]` becomes - // the "model exists but doesn't support this endpoint" 400. - readonly kind: ModelKind; - // Endpoint-availability gate against a resolved candidate's `InternalModel`. - // Reads `.endpoints` on the candidate — the row narrows to exactly one - // contributing upstream, so those endpoints come verbatim from the emitting - // upstream's `ProviderModel`. - readonly modelServesEndpoint: (model: InternalModel) => boolean; - // Any throw here is preserved and becomes a 502 with the internal-debug - // envelope. `model` is the emitting upstream's `ProviderModel`. - readonly call: (provider: Provider, model: ProviderModel, opts: UpstreamCallOptions) => Promise; - readonly response: PassthroughResponseHandling; -} - -// Uniform error envelope for this endpoint family. -export const passthroughApiError = (c: Context, message: string, status: ContentfulStatusCode): Response => - c.json({ error: { message, type: 'api_error' } }, status); - -export const passthroughServe = async (input: PassthroughServeContext): Promise => { - const { c, ctx, sourceApi, operation, model, kind, modelServesEndpoint, call, response: responseHandling } = input; - - try { - // The shared resolver returns every candidate of the requested kind: - // unprefixed + prefixed addressable surfaces fan out across upstreams, - // and the kind filter rejects models of the wrong family before they - // reach the endpoint check below. Iteration order follows configured - // sort_order across upstreams, with the unprefixed branch pushed before - // the prefixed one within a single upstream. The first candidate whose - // endpoint-key check passes wins. - // - // Alias resolution is a top-of-chain step inside the resolver: an alias - // id walks every target in `selection` order, tags each returned - // candidate with that target's rule overlay, and dedups across the - // flattened list. Passthrough endpoints never consult that overlay, so - // whatever rules a target carries are inert here — the alias flow only - // changes which id the gateway addresses upstream. - const { candidates, sawModel, failedUpstreams } = await enumerateModelCandidates({ - subject: ctx.user, - upstreamIds: ctx.upstreamIds, - randomizeUpstreams: ctx.randomizeUpstreams, - model, - kind, - scheduler: ctx.backgroundScheduler, - runtimeLocation: ctx.runtimeLocation, - }); - if (candidates.length === 0) { - ctx.dump?.error('gateway'); - // `sawModel` includes only Model.Use-permitted catalogs. With no candidates, - // false keeps hidden/missing ids at 404; true identifies wrong-kind matches - // (400), like the empty-viable case below. - return sawModel - ? passthroughApiError(c, appendFailedUpstreams(`Model ${model} does not support the ${sourceApi} endpoint.`, failedUpstreams), 400) - : passthroughApiError(c, appendFailedUpstreams(`Model ${model} is not available on any configured upstream.`, failedUpstreams), 404); - } - - // Endpoint-level pre-filter: drop candidates whose upstream model - // exists for the requested kind but doesn't expose this endpoint's - // specific capability (e.g. an embedding-kind model on an upstream - // that only exposes chat). An empty viable set is the same "model - // exists but no upstream serves this endpoint" 400 the empty-candidate - // branch above surfaces. - const viable = candidates.filter(c => modelServesEndpoint(c.model)); - if (viable.length === 0) { - ctx.dump?.error('gateway'); - return passthroughApiError(c, appendFailedUpstreams(`Model ${model} does not support the ${sourceApi} endpoint.`, failedUpstreams), 400); - } - - // Iterate the viable list. Each candidate's attempt runs the upstream - // HTTP call and records performance telemetry; the shared - // iterator returns the first 2xx or, on exhaustion, the last non-2xx - // result. Request-perf and dump attribution wait until this point so - // they land against the terminal candidate. - const result = await iterateCandidates( - viable, - 'passthroughServe', - ctx, - operation, - candidate => passthroughAttempt({ - c, ctx, candidate, operation, - call, - }), - ); - const { response, performance: performanceContext, identity } = result; - - if (responseHandling.format === 'strategy') { - return await responseHandling.respond({ c, ctx, sourceApi, response, performance: performanceContext, identity }); - } - - if (!response.ok) { - // Exhausted — forward the last upstream response verbatim so clients - // still see real upstream telemetry (status, retry-after, request-id, - // ...) rather than a synthetic gateway envelope. - recordFailedRequest(ctx, performanceContext); - ctx.dump?.error('upstream', identity.upstream); - return forwardUpstreamResponse(response); - } - - if (responseHandling.format === 'json') { - // A 2xx body that fails to parse must not 502 a client whose - // upstream call already succeeded; we skip usage extraction and - // log so missing rows stay traceable. - let parsed: unknown; - try { - parsed = await response.clone().json(); - } catch (e) { - console.warn(`passthrough-serve: failed to parse 2xx upstream body for ${sourceApi}; usage row will be skipped`, e instanceof Error ? e.message : String(e)); - parsed = undefined; - } - const usage = parsed !== undefined ? responseHandling.extractBilling(parsed) : null; - ctx.dump?.success(identity, usage); - settle(ctx, performanceContext, identity, usage, false); - return forwardUpstreamResponse(response); - } - - // Hono's streamSSE owns the response — forwardable upstream - // headers must be staged on `c` *before* the streamSSE call so - // they survive its internal newResponse. - const upstreamBody = response.body; - if (!upstreamBody) { - ctx.dump?.failed(`${sourceApi} streaming upstream returned no body`); - recordFailedRequest(ctx, performanceContext); - // Preserve upstream correlation headers (x-request-id, cf-ray, ...) - // on the synthesized 502 so this rare edge case is still traceable. - forwardUpstreamHeaders(c, response.headers); - return passthroughApiError(c, 'Upstream returned a streaming response with no body.', 502); - } - forwardUpstreamHeaders(c, response.headers); - return streamSSE(c, async stream => { - let completion: StreamCompletion = 'error'; - let streamError: unknown; - // Tracks whether the upstream's terminal (`done`) frame arrived - // before the writer settled. A client cancel after the terminal - // frame is graceful (upstream already finished its work); a - // mid-stream cancel or EOF without terminal is a real failure. - // Mirrors SourceStreamState.failedAfter on the chat endpoints. - let terminalFrameSeen = false; - try { - const frames = (async function* () { - const sseFramesIn = parseSSEStream(upstreamBody, { signal: ctx.abortSignal }); - for await (const parsed of parseTargetStreamFrames(sseFramesIn, { protocol: sourceApi })) { - const inputFrame: ProtocolFrame = parsed.type === 'done' ? doneFrame() : eventFrame(parsed.data); - // Dump pre-transform, so forensics see upstream truth even - // when the caller drops a frame from the client-facing stream. - ctx.dump?.frame(inputFrame); - if (inputFrame.type === 'done') terminalFrameSeen = true; - const outputFrame = responseHandling.transformFrame(inputFrame); - if (outputFrame === null) continue; - yield outputFrame.type === 'done' ? sseFrame('[DONE]') : sseFrame(JSON.stringify(outputFrame.event)); - } - })(); - completion = await writeSSEFrames(stream, frames, { - keepAlive: { frame: sseCommentFrame('keepalive') }, - downstreamAbortController: ctx.downstreamAbortController, - }); - } catch (e) { - streamError = e; - } finally { - const usage = responseHandling.settleUsage(); - const failed = streamError !== undefined || completion === 'error' || !terminalFrameSeen; - if (failed) { - ctx.dump?.failed(streamError ?? `${sourceApi} stream ended with completion=${completion}`); - } else { - ctx.dump?.success(identity, usage); - } - // Record any accumulated usage regardless of the failed flag — - // tokens already metered upstream should bill even when the - // downstream half of the round-trip turned out badly. The chat - // streaming endpoints follow the same rule. - settle(ctx, performanceContext, identity, usage, failed); - } - }); - } catch (e) { - if (e instanceof ProviderModelsUnavailableError) { - const forwarded = httpResponseToResponse(e.httpResponse); - if (forwarded) { - ctx.dump?.error('upstream'); - return forwarded; - } - } - // Attributes to whichever candidate iterateCandidates was on (or short-circuits if none started). - recordFailedRequest(ctx, ctx.attempt.telemetry); - ctx.dump?.failed(e); - return c.json({ error: toInternalDebugError(e) }, 502); - } -}; diff --git a/packages/gateway/src/data-plane/shared/upstream-call-options.ts b/packages/gateway/src/data-plane/shared/upstream-call-options.ts index 03372b868f..6eed9add8c 100644 --- a/packages/gateway/src/data-plane/shared/upstream-call-options.ts +++ b/packages/gateway/src/data-plane/shared/upstream-call-options.ts @@ -10,7 +10,7 @@ export const buildUpstreamCallOptions = ( ctx: GatewayCtx, headers: Headers, ): UpstreamCallOptions => ({ - fetcher: ctx.dump?.http.wrapFetcher(candidate.fetcher, candidate.provider.upstreamId) ?? candidate.fetcher, + fetcher: ctx.dump?.http?.wrapFetcher(candidate.fetcher, candidate.provider.upstreamId) ?? candidate.fetcher, waitUntil: ctx.backgroundScheduler, headers: filterInboundHeadersForProvider(headers, candidate.provider), wrapUpstreamCall: stampUpstreamCallStart(ctx.attempt.timing), diff --git a/packages/gateway/src/data-plane/tools/web-search/alpha-search/relay-response.ts b/packages/gateway/src/data-plane/tools/web-search/alpha-search/relay-response.ts deleted file mode 100644 index d990344a7a..0000000000 --- a/packages/gateway/src/data-plane/tools/web-search/alpha-search/relay-response.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Alpha-search upstream Fetch decodes a response's content coding before exposing its body stream, -// but keeps the upstream Content-Encoding and Content-Length headers. Relaying -// that stream with the stale representation headers makes the next Fetch -// consumer decode plain bytes a second time. Rebuild the response around the -// decoded stream and preserve every header that still describes it. - -const BLOCKED_RELAY_HEADERS: ReadonlySet = new Set([ - // Hop-by-hop headers (RFC 9110 §7.6.1). - // https://www.rfc-editor.org/rfc/rfc9110#section-7.6.1 - 'connection', - 'keep-alive', - 'proxy-authenticate', - 'proxy-authorization', - 'te', - 'trailer', - 'transfer-encoding', - 'upgrade', - // Fetch owns the decoded body's representation framing. - 'content-encoding', - 'content-length', - // Upstream session cookies must not bind a gateway client. - 'set-cookie', - 'set-cookie2', -]); - -export const relayFetchedResponse = (response: Response): Response => { - const headers = new Headers(); - for (const [name, value] of response.headers) { - if (!BLOCKED_RELAY_HEADERS.has(name.toLowerCase())) headers.set(name, value); - } - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }); -}; diff --git a/packages/gateway/src/data-plane/tools/web-search/alpha-search/upstream.ts b/packages/gateway/src/data-plane/tools/web-search/alpha-search/upstream.ts index 17f9052031..af403b703a 100644 --- a/packages/gateway/src/data-plane/tools/web-search/alpha-search/upstream.ts +++ b/packages/gateway/src/data-plane/tools/web-search/alpha-search/upstream.ts @@ -3,7 +3,7 @@ import { enumerateModelCandidates } from '../../../providers/resolution.ts'; import { filterInboundHeadersForProvider } from '../../../shared/inbound-headers.ts'; import type { WebSearchConfig } from '../types.ts'; import type { BackgroundScheduler } from '@floway-dev/platform'; -import { identityWrapUpstreamCall, providerModelOf } from '@floway-dev/provider'; +import { identityWrapUpstreamCall, providerModelOf, type ModelCandidate } from '@floway-dev/provider'; export type AlphaSearchDispatcher = (body: Record, signal: AbortSignal | undefined, headers: Headers) => Promise; @@ -14,19 +14,23 @@ export class AlphaSearchModelNotFoundError extends Error { } } -export const resolveAlphaSearchDispatcher = async ({ - config, - subject, - upstreamIds, - scheduler, - runtimeLocation, -}: { +interface AlphaSearchResolution { config: Pick; subject: User; upstreamIds: readonly string[] | null; scheduler: BackgroundScheduler; runtimeLocation: string; -}): Promise => { +} + +/** The operator's pinned search candidate. The configured model is private, so a key outside + * its scope and a model that is missing share one answer. */ +export const resolveAlphaSearchCandidate = async ({ + config, + subject, + upstreamIds, + scheduler, + runtimeLocation, +}: AlphaSearchResolution): Promise => { if (upstreamIds !== null && !upstreamIds.includes(config.upstreamId)) { throw new AlphaSearchModelNotFoundError(); } @@ -46,7 +50,11 @@ export const resolveAlphaSearchDispatcher = async ({ if (candidate.provider.kind !== 'codex' && candidate.provider.kind !== 'custom') { throw new Error('Selected upstream does not support OpenAI search passthrough'); } + return candidate; +}; +export const resolveAlphaSearchDispatcher = async (resolution: AlphaSearchResolution): Promise => { + const candidate = await resolveAlphaSearchCandidate(resolution); return async (body, signal, headers) => { const { model: _callerModel, ...request } = body; // TODO: pin SearchRequest.id to one provider account when Codex upstreams @@ -57,7 +65,7 @@ export const resolveAlphaSearchDispatcher = async ({ signal, { fetcher: candidate.fetcher, - waitUntil: scheduler, + waitUntil: resolution.scheduler, headers: filterInboundHeadersForProvider(headers, candidate.provider), wrapUpstreamCall: identityWrapUpstreamCall, }, diff --git a/packages/gateway/src/dump/accumulator.ts b/packages/gateway/src/dump/accumulator.ts index 87c15943c1..61b48c8d52 100644 --- a/packages/gateway/src/dump/accumulator.ts +++ b/packages/gateway/src/dump/accumulator.ts @@ -8,21 +8,15 @@ import type { Context } from 'hono'; import { HttpCapture } from './http-capture.ts'; import { getDumpBroker, getDumpStore } from './registry.ts'; -import type { - DumpErrorMeta, - DumpMetadata, - DumpStreamEvent, - DumpUpstreamRef, - DumpWriteRecord, - PreparedDumpRequestBody, - StoredDumpResponseBody, -} from './types.ts'; +import type { StreamRecording } from './turn-dump.ts'; +import type { DumpStreamEvent, DumpWriteRecord, PreparedDumpRequestBody, StoredDumpResponseBody } from './types.ts'; import { encodeBodyForWire } from './wire.ts'; import { attemptTtftMs, type AttemptTiming } from '../data-plane/shared/attempt-timing.ts'; import type { RequestBody } from '../data-plane/shared/request-body.ts'; import { getRepo } from '../repo/index.ts'; import type { ApiKey, TokenUsage } from '../repo/types.ts'; import { ulid } from '../shared/ulid.ts'; +import type { DumpErrorMeta, DumpMetadata, DumpUpstreamRef } from '@floway-dev/dump/types'; import type { BackgroundScheduler } from '@floway-dev/platform'; import { type ProtocolFrame } from '@floway-dev/protocols/common'; import type { ChatTargetApi, TelemetryModelIdentity } from '@floway-dev/provider'; @@ -155,6 +149,10 @@ export class DumpAccumulator { this.events.push({ frame, ts: Date.now() - this.startedAt }); } + openStream(): StreamRecording { + return { frame: frame => { this.frame(frame); }, end: () => {}, fact: null }; + } + // --- pre-translation upstream hooks (called from `executeTranslation`) --- // Stamped eagerly at capture construction so `meta.targetApi` is set even @@ -328,6 +326,7 @@ export class DumpAccumulator { // Commit the row before publishing so subscribers fetching detail off the meta frame find it. try { const record: DumpWriteRecord = { + shape: 'edge', capture: { exchanges: this.http.exchanges, ...(response.rawCaptured ? { diff --git a/packages/gateway/src/dump/attribution.ts b/packages/gateway/src/dump/attribution.ts new file mode 100644 index 0000000000..f9d3d438eb --- /dev/null +++ b/packages/gateway/src/dump/attribution.ts @@ -0,0 +1,137 @@ +// What a turn's record says about the turn, and what fills it. +// +// `DumpMetadata` is common to both record shapes — the dashboard lists a run +// and a pair of edges in the same list, and a turn's model, upstream and token +// counts do not depend on which mechanism served it — so the hooks that stamp +// attribution and the assembly that turns it into metadata are one thing rather +// than one per shape. +// +// Four independent slots the mid-flight hooks fill: `model` and `upstreamId` +// identify what the turn was about, `inputTokens` / `outputTokens` quantify +// what the upstream reported. They're independent because different outcomes +// set different subsets: +// +// • Every protocol handler calls `requestedModel(model)` immediately after +// parsing the payload, so `model` is set regardless of outcome. +// • `success(identity, usage)` fills all four; the upstream-resolved model +// id may overwrite what `requestedModel` had. +// • `error(kind, upstream?)` records a categorized api-error envelope +// (`kind` matches `ApiErrorResult.source`). Real upstream non-2xx pass +// `upstream` so a 4xx/5xx row in the dashboard names the upstream that +// rejected the call; the gateway arm may also pass it when a candidate +// was already chosen (item-not-found rewrite, server-tool input +// rejection). +// • `failed(reason)` records an uncategorized terminal failure: a thrown +// exception, a source-emitted error frame, a downstream cancel, or a +// writer error. Caller passes a string or Error; this one-line-formats it +// (`.message` only — never the stack, which lives in the response body's +// debug envelope). +// +// `requestedModel`-set model survives across both error variants so even an +// outright-failed turn carries model attribution. + +import { getRepo } from '../repo/index.ts'; +import type { TokenUsage } from '../repo/types.ts'; +import type { DumpErrorMeta, DumpMetadata, DumpUpstreamRef } from '@floway-dev/dump/types'; +import type { TelemetryModelIdentity } from '@floway-dev/provider'; + +export const oneLineError = (err: unknown): string => { + const msg = (err instanceof Error ? err.message : String(err)).replace(/\s+/g, ' ').trim(); + return msg.length > 500 ? `${msg.slice(0, 497)}…` : msg; +}; + +// Anthropic-style disjoint per-category counts: input excludes cache reads +// and cache writes; sum the present ones onto the dump's single inputTokens +// column. Missing categories stay null (not measured) instead of zero so a +// recorded zero genuinely means "upstream said zero". +const tokenTotal = (usage: TokenUsage | null, keys: readonly (keyof TokenUsage)[]): number | null => { + if (usage === null) return null; + const present = keys.map(key => usage[key]).filter((value): value is number => typeof value === 'number'); + return present.length === 0 ? null : present.reduce((sum, value) => sum + value, 0); +}; + +const resolveUpstreamRef = async (id: string | null): Promise => { + if (!id) return null; + const upstream = await getRepo().upstreams.getById(id); + if (!upstream) return null; + return { id: upstream.id, name: upstream.name, kind: upstream.kind, hue: upstream.hue }; +}; + +// What only the recording side knows: identity, timing and the measured sizes +// of the two edges. Everything else on the metadata comes from the hooks. +export interface DumpTurnOutcome { + readonly id: string; + readonly startedAt: number; + readonly completedAt: number; + readonly method: string; + readonly path: string; + readonly status: number | null; + readonly requestBytes: number; + readonly responseBytes: number; + readonly ttftMs: number | null; + // Applied only when no hook stamped an error, so an explicit stamp from the + // respond path always outranks a transport-level read failure. + readonly fallbackError: DumpErrorMeta | null; +} + +// A request-body read failure — the operator-side payload did not arrive intact +// — outranks a failure reading back what was answered. Both surface as +// `kind: 'failed'`. +export const streamReadError = (request: string | null, response: string | null): DumpErrorMeta | null => { + if (request !== null) return { kind: 'failed', reason: request }; + if (response !== null) return { kind: 'failed', reason: response }; + return null; +}; + +export class DumpAttribution { + private model: string | null = null; + private upstreamId: string | null = null; + private inputTokens: number | null = null; + private outputTokens: number | null = null; + private errorMeta: DumpErrorMeta | null = null; + private settlementError: DumpErrorMeta | null = null; + + requestedModel(model: string): void { + this.model = model; + } + + error(kind: 'upstream' | 'gateway', upstream?: string): void { + this.errorMeta = { kind }; + if (upstream !== undefined) this.upstreamId = upstream; + } + + failed(reason: unknown, options?: { readonly fallback: boolean }): void { + const error: DumpErrorMeta = { kind: 'failed', reason: typeof reason === 'string' ? reason : oneLineError(reason) }; + if (options?.fallback === true) this.settlementError = error; + else this.errorMeta = error; + } + + success(identity: TelemetryModelIdentity, usage: TokenUsage | null): void { + this.model = identity.model; + this.upstreamId = identity.upstream; + const input = tokenTotal(usage, ['input', 'input_cache_read', 'input_cache_write', 'input_cache_write_1h', 'input_image']); + if (input !== null) this.inputTokens = (this.inputTokens ?? 0) + input; + const output = tokenTotal(usage, ['output', 'output_image']); + if (output !== null) this.outputTokens = (this.outputTokens ?? 0) + output; + } + + async metadata(outcome: DumpTurnOutcome): Promise { + return { + id: outcome.id, + startedAt: outcome.startedAt, + completedAt: outcome.completedAt, + method: outcome.method, + path: outcome.path, + status: outcome.status, + upstream: await resolveUpstreamRef(this.upstreamId), + model: this.model, + inputTokens: this.inputTokens, + outputTokens: this.outputTokens, + requestBytes: outcome.requestBytes, + responseBytes: outcome.responseBytes, + durationMs: outcome.completedAt - outcome.startedAt, + ttftMs: outcome.ttftMs, + error: this.errorMeta ?? outcome.fallbackError ?? this.settlementError, + }; + } +} diff --git a/packages/gateway/src/dump/run-sink.ts b/packages/gateway/src/dump/run-sink.ts new file mode 100644 index 0000000000..5d5c0ab632 --- /dev/null +++ b/packages/gateway/src/dump/run-sink.ts @@ -0,0 +1,155 @@ +import { DumpAttribution, oneLineError, streamReadError } from './attribution.ts'; +import { getDumpBroker, getDumpStore } from './registry.ts'; +import { attemptTtftMs, type AttemptTiming } from '../data-plane/shared/attempt-timing.ts'; +import type { RequestBody } from '../data-plane/shared/request-body.ts'; +import type { ApiKey, TokenUsage } from '../repo/types.ts'; +import { ulid } from '../shared/ulid.ts'; +import { createRunRecorder, type DumpMetadata, type RunRecorder, type StreamRecording } from '@floway-dev/dump'; +import type { BackgroundScheduler } from '@floway-dev/platform'; +import type { ProtocolFrame } from '@floway-dev/protocols/common'; +import type { TelemetryModelIdentity } from '@floway-dev/provider'; + +// Only metadata is snapshotted here; request contents are facts recorded by the run. +interface RequestSnapshot { + readonly method: string; + readonly path: string; + readonly bodyByteLength: number; + readonly streamError: string | null; +} + +export class RunDump { + private readonly attribution = new DumpAttribution(); + private readonly recorder: RunRecorder; + readonly sink: RunRecorder['sink']; + private runDrain: (() => Promise) | null = null; + private sentPayloadBytes = 0; + + constructor( + apiKey: ApiKey, + private readonly requestSnapshot: RequestSnapshot, + private readonly startedAt: number, + private readonly backgroundScheduler: BackgroundScheduler, + private readonly wantsStream: boolean, + private readonly timing: AttemptTiming, + ) { + this.recorder = createRunRecorder({ + write: record => getDumpStore().put(apiKey.id, { shape: 'run', ...record }), + publish: meta => getDumpBroker().publish(apiKey.id, meta), + }); + this.sink = this.recorder.sink; + } + + afterRun(drain: () => Promise): void { this.runDrain = drain; } + + requestedModel(model: string): void { + this.attribution.requestedModel(model); + } + + error(kind: 'upstream' | 'gateway', upstream?: string): void { + this.attribution.error(kind, upstream); + } + + failed(reason: unknown, options?: { readonly fallback: boolean }): void { + this.attribution.failed(reason, options); + } + + frame(frame: ProtocolFrame): void | Promise { + return this.recorder.frame(frame); + } + + openStream(): StreamRecording { return this.recorder.openStream(); } + + success(identity: TelemetryModelIdentity, usage: TokenUsage | null): void { + this.attribution.success(identity, usage); + } + + recordSentPayloadBytes(byteLength: number): void { + this.sentPayloadBytes += byteLength; + } + + finalize(status: number | null, responseBytes: number): void; + finalize(response: Response): Response; + finalize(...args: [number | null, number] | [Response]): void | Response { + if (args.length === 2) { + const [status, responseBytes] = args; + this.backgroundScheduler(this.write(status, responseBytes + this.sentPayloadBytes, null)); + return; + } + + const [response] = args; + if (response.body === null) { + this.finalize(response.status, 0); + return response; + } + + const [forClient, forMeasure] = response.body.tee(); + this.backgroundScheduler((async () => { + const reader = forMeasure.getReader(); + let payloadBytes = 0; + let streamError: string | null = null; + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + payloadBytes += value.byteLength; + } + } catch (err) { + streamError = oneLineError(err); + } finally { + reader.releaseLock(); + } + await this.write(response.status, payloadBytes, streamError); + })()); + + return new Response(forClient, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + } + + private async write(status: number | null, responseBytes: number, responseStreamError: string | null): Promise { + if (this.runDrain !== null) { + try { await this.runDrain(); } catch (error) { this.failed(error); } + } + const completedAt = Date.now(); + const recordId = ulid(completedAt); + const meta: DumpMetadata = await this.attribution.metadata({ + id: recordId, + startedAt: this.startedAt, + completedAt, + method: this.requestSnapshot.method, + path: this.requestSnapshot.path, + status, + requestBytes: this.requestSnapshot.bodyByteLength, + responseBytes, + ttftMs: this.wantsStream ? attemptTtftMs(this.timing) : null, + fallbackError: streamReadError(this.requestSnapshot.streamError, responseStreamError), + }); + + await this.recorder.finish(meta); + } +} + +export const openRunDump = ( + apiKey: ApiKey, + turn: { readonly method: string; readonly path: string; readonly body: RequestBody }, + backgroundScheduler: BackgroundScheduler, + wantsStream: boolean, + timing: AttemptTiming, +): RunDump | null => { + if (apiKey.dumpRetentionSeconds === null) return null; + return new RunDump( + apiKey, + { + method: turn.method, + path: turn.path, + bodyByteLength: turn.body.bytes.byteLength, + streamError: turn.body.streamError, + }, + Date.now(), + backgroundScheduler, + wantsStream, + timing, + ); +}; diff --git a/packages/gateway/src/dump/schemas.ts b/packages/gateway/src/dump/schemas.ts index 2a4deaccc1..307b5e5557 100644 --- a/packages/gateway/src/dump/schemas.ts +++ b/packages/gateway/src/dump/schemas.ts @@ -8,7 +8,7 @@ export const dumpHeadersSchema = z.array(z.tuple([z.string(), z.string()])); export const dumpBodyDescriptorSchema = z.object({ key: z.string(), - type: z.enum(['bytes', 'events', 'capture']), + type: z.enum(['bytes', 'events', 'run', 'capture']), }).strict(); const dumpProtocolFrameSchema = z.discriminatedUnion('type', [ diff --git a/packages/gateway/src/dump/storage-codec.ts b/packages/gateway/src/dump/storage-codec.ts index c4387befa4..576a876fd6 100644 --- a/packages/gateway/src/dump/storage-codec.ts +++ b/packages/gateway/src/dump/storage-codec.ts @@ -6,7 +6,8 @@ import { persistedDumpMetadataSchema, dumpStreamEventsSchema, } from './schemas.ts'; -import type { DumpMetadata, DumpStreamEvent } from './types.ts'; +import type { DumpStreamEvent } from './types.ts'; +import type { DumpMetadata } from '@floway-dev/dump/types'; export type DumpBodyDescriptor = z.infer; type PersistedDumpMetadata = z.infer; diff --git a/packages/gateway/src/dump/store-contract.ts b/packages/gateway/src/dump/store-contract.ts index ffa1a51fc7..b652fa8e71 100644 --- a/packages/gateway/src/dump/store-contract.ts +++ b/packages/gateway/src/dump/store-contract.ts @@ -1,4 +1,5 @@ -import type { DumpMetadata, DumpRecordId, DumpWriteRecord, PreparedDumpRequestBody, StoredDumpRecord } from './types.ts'; +import type { DumpWriteRecord, PreparedDumpRequestBody, StoredDumpRecord } from './types.ts'; +import type { DumpMetadata, DumpRecordId } from '@floway-dev/dump/types'; // Per-API-key request dump storage contract: metadata in SQL, bodies in the // FileStore. Request bytes are prepared before the terminal write; reads diff --git a/packages/gateway/src/dump/turn-dump.ts b/packages/gateway/src/dump/turn-dump.ts new file mode 100644 index 0000000000..dc00d0250a --- /dev/null +++ b/packages/gateway/src/dump/turn-dump.ts @@ -0,0 +1,50 @@ +// What a turn stamps on its recording, whichever shape that recording is. +// +// Two shapes are alive while the migration runs: the edge record, which holds what the client +// sent and what it got back, and the run record, which holds every stage of the pipeline as +// an event stream. The shape follows the endpoint — a pipelined one produces the run shape — +// and the stages in between neither know nor care which they are stamping. + +import type { HttpCapture } from './http-capture.ts'; +import type { TokenUsage } from '../repo/types.ts'; +import type { StreamFact } from '@floway-dev/pipeline'; +import type { ProtocolFrame } from '@floway-dev/protocols/common'; +import type { TelemetryModelIdentity } from '@floway-dev/provider'; + +/** + * One stream, as the recording knows it. + * + * A run record identifies its streams, because their content arrives over time and after the + * fact that holds them: the fact carries `{"$stream": n}` and the frames arrive afterwards + * naming that id. `end` is what says the record of this stream is complete — a client that + * stopped reading leaves it short, and the absence of the terminator is how a reader tells a + * stream that ended from one that was cut off. + * + * The edge record has one frame log and no way to name anything in it, so its `fact` is null + * and its terminator has nothing to write. That is the shape's limit rather than an omission, + * and it goes away with the shape. + */ +export interface StreamRecording { + frame(frame: ProtocolFrame): void | Promise; + end(): void | Promise; + readonly fact: StreamFact | null; +} + +export interface TurnDump { + readonly http?: HttpCapture; + requestedModel(model: string): void; + success(identity: TelemetryModelIdentity, usage: TokenUsage | null): void; + error(kind: 'upstream' | 'gateway', upstream?: string): void; + failed(reason: unknown, options?: { readonly fallback: boolean }): void; + frame(frame: ProtocolFrame): void | Promise; + /** Begins recording one stream. Every call is a new one, which is what lets a turn that + * opens two — a sub-request beside the answer — keep them apart. */ + openStream(): StreamRecording; + /** How much of the answer actually went out. A transport that writes its own frames counts + * them itself, because nothing downstream of it can. */ + recordSentPayloadBytes(byteLength: number): void; + /** Closes the recording. A transport that owns its own response — the WebSocket turn — + * states what it sent; an HTTP one hands over the response so the bytes can be teed. */ + finalize(status: number | null, responseBytes: number | readonly unknown[]): void; + finalize(response: Response): Response; +} diff --git a/packages/gateway/src/dump/types.ts b/packages/gateway/src/dump/types.ts index 847c2abe3b..74e05a547d 100644 --- a/packages/gateway/src/dump/types.ts +++ b/packages/gateway/src/dump/types.ts @@ -16,7 +16,6 @@ import type { dumpStreamEventSchema, } from './schemas.ts'; import type { DumpMetadata } from '@floway-dev/dump/types'; -export type { DumpRecordId, DumpErrorMeta, DumpMetadata, DumpUpstreamRef } from '@floway-dev/dump/types'; export type DumpCapture = z.infer; @@ -82,14 +81,16 @@ export interface StoredDumpResponse { readonly upstream?: StoredDumpUpstreamResponse; } -export type StoredDumpRecord = { +export type StoredDumpEdgeRecord = { + shape: 'edge'; capture?: DumpCapture; meta: DumpMetadata; request: StoredDumpRequest; response: StoredDumpResponse; }; -export type DumpWriteRecord = { +export type DumpWriteEdgeRecord = { + shape: 'edge'; capture?: DumpCapture; meta: DumpMetadata; request: DumpWriteRequest; @@ -131,9 +132,27 @@ interface DumpResponse { readonly upstream?: DumpUpstreamResponse; } -export type DumpRecord = { +export type DumpEdgeRecord = { + shape: 'edge'; capture?: DumpCapture; meta: DumpMetadata; request: DumpRequest; response: DumpResponse; }; + +// A run's event stream records every stage and both directions. +export type StoredDumpRunRecord = { + shape: 'run'; + meta: DumpMetadata; + events: Uint8Array; +}; + +export type DumpRunRecord = { + shape: 'run'; + meta: DumpMetadata; + events: string; +}; + +export type StoredDumpRecord = StoredDumpEdgeRecord | StoredDumpRunRecord; +export type DumpWriteRecord = DumpWriteEdgeRecord | StoredDumpRunRecord; +export type DumpRecord = DumpEdgeRecord | DumpRunRecord; diff --git a/packages/gateway/src/dump/wire.ts b/packages/gateway/src/dump/wire.ts index 01b055941f..448a58bb57 100644 --- a/packages/gateway/src/dump/wire.ts +++ b/packages/gateway/src/dump/wire.ts @@ -7,28 +7,12 @@ import type { StoredDumpResponseBody, StoredDumpUpstreamResponse, } from './types.ts'; +import { redactRunCredentials, redactSensitiveHeaders } from '@floway-dev/dump'; import { encodeBase64, isTextualMediaType } from '@floway-dev/protocols/common'; const contentTypeOf = (headers: ReadonlyArray): string => headers.find(([name]) => name.toLowerCase() === 'content-type')?.[1] ?? ''; -const SENSITIVE_HEADERS = new Set([ - 'api-key', - 'authorization', - 'cookie', - 'proxy-authorization', - 'set-cookie', - 'x-api-key', - 'x-goog-api-key', -]); - -const REDACTED_HEADER_VALUE = '[redacted]'; - -// The owner of a key may read the dump, but the upstream credentials in it -// belong to an operator who granted Model.Use, not to that key owner. -const redactSensitiveHeaders = (headers: ReadonlyArray): Array<[string, string]> => - headers.map(([name, value]) => [name, SENSITIVE_HEADERS.has(name.toLowerCase()) ? REDACTED_HEADER_VALUE : value]); - // Wire encoding decision: textual content-types try UTF-8 first and fall // back to base64 when the bytes do not decode cleanly (a content-type // that lied about being text). @@ -63,33 +47,43 @@ export const upstreamResponseToWire = ( }; // Converts stored bytes to the JSON representation served by the control plane. -export const dumpRecordToWire = (record: StoredDumpRecord): DumpRecord => ({ - ...(record.capture === undefined ? {} : { - capture: { - ...record.capture, - exchanges: record.capture.exchanges.map(exchange => ({ - ...exchange, - request: { ...exchange.request, headers: redactSensitiveHeaders(exchange.request.headers) }, - response: exchange.response === null ? null : { - ...exchange.response, - headers: redactSensitiveHeaders(exchange.response.headers), - }, - })), +export const dumpRecordToWire = (record: StoredDumpRecord): DumpRecord => { + if (record.shape === 'run') { + return { + shape: 'run', + meta: record.meta, + events: redactRunCredentials(new TextDecoder('utf-8', { fatal: true }).decode(record.events)), + }; + } + return { + shape: 'edge', + ...(record.capture === undefined ? {} : { + capture: { + ...record.capture, + exchanges: record.capture.exchanges.map(exchange => ({ + ...exchange, + request: { ...exchange.request, headers: redactSensitiveHeaders(exchange.request.headers) }, + response: exchange.response === null ? null : { + ...exchange.response, + headers: redactSensitiveHeaders(exchange.response.headers), + }, + })), + }, + }), + meta: record.meta, + request: { + method: record.request.method, + path: record.request.path, + headers: record.request.headers, + body: encodeBodyForWire(record.request.body, contentTypeOf(record.request.headers)), + }, + response: { + status: record.response.status, + headers: record.response.headers, + body: responseBodyToWire(record.response.body, contentTypeOf(record.response.headers)), + ...(record.response.upstream !== undefined ? { + upstream: upstreamResponseToWire(record.response.upstream), + } : {}), }, - }), - meta: record.meta, - request: { - method: record.request.method, - path: record.request.path, - headers: record.request.headers, - body: encodeBodyForWire(record.request.body, contentTypeOf(record.request.headers)), - }, - response: { - status: record.response.status, - headers: record.response.headers, - body: responseBodyToWire(record.response.body, contentTypeOf(record.response.headers)), - ...(record.response.upstream !== undefined ? { - upstream: upstreamResponseToWire(record.response.upstream), - } : {}), - }, -}); + }; +}; diff --git a/packages/gateway/src/repo/dump-store.ts b/packages/gateway/src/repo/dump-store.ts index fa7e23cc83..102bd166cc 100644 --- a/packages/gateway/src/repo/dump-store.ts +++ b/packages/gateway/src/repo/dump-store.ts @@ -13,24 +13,14 @@ import { } from '../dump/storage-codec.ts'; import type { DumpBodyDescriptor } from '../dump/storage-codec.ts'; import type { DumpListOptions, DumpStore } from '../dump/store-contract.ts'; -import type { - DumpMetadata, - DumpRecordId, - DumpUpstreamRef, - DumpWriteRecord, - PreparedDumpRequestBody, - StoredDumpRecord, - StoredDumpRequest, - StoredDumpResponse, - StoredDumpResponseBody, - StoredDumpUpstreamResponse, -} from '../dump/types.ts'; +import type { DumpWriteRecord, PreparedDumpRequestBody, StoredDumpRecord, StoredDumpEdgeRecord, StoredDumpRequest, StoredDumpResponse, StoredDumpResponseBody, StoredDumpUpstreamResponse } from '../dump/types.ts'; import { upstreamResponseToWire } from '../dump/wire.ts'; import { gunzipBytes, gzipBytes } from '../shared/gzip.ts'; +import type { DumpMetadata, DumpRecordId, DumpUpstreamRef } from '@floway-dev/dump/types'; import type { FileStore, SqlDatabase } from '@floway-dev/platform'; import { decodeForgivingBase64 } from '@floway-dev/protocols/common'; -// Bodies live at `dumps/v1/{keyId}/{YYYYMMDDHH}/{recordId}-{uniqueSuffix}.{req|resp}.gz`. +// Bodies live at `dumps/v1/{keyId}/{YYYYMMDDHH}/{recordId}-{uniqueSuffix}.{req|resp|run}.gz`. // The hour segment remains useful for operator inspection; lifecycle and // collection are driven by the shared spilled_files registry. @@ -77,7 +67,7 @@ const hourBucket = (ms: number): string => { return `${y}${m}${d}${h}`; }; -const bodyPath = (keyId: string, bucket: string, recordId: string, side: 'req' | 'resp' | 'resp.up'): string => +const bodyPath = (keyId: string, bucket: string, recordId: string, side: 'req' | 'resp' | 'resp.up' | 'run'): string => `${DUMP_FILE_PREFIX}${keyId}/${bucket}/${recordId}-${crypto.randomUUID()}.${side}.gz`; const putRawBody = async ( @@ -107,6 +97,12 @@ const fetchBody = async (files: FileStore, descriptor: DumpBodyDescriptor): Prom return await gunzipBytes(gz); }; +// A run record has no edge halves at row level — its request and response are +// events inside the stream — and `request_headers_json` is NOT NULL. The empty +// list is how the row spells "this shape has none"; nothing reads it back, +// because `get` dispatches on the body descriptor first. +const NO_EDGE_HEADERS = '[]'; + export class FileDumpStore implements DumpStore { constructor(private readonly db: SqlDatabase, private readonly files: FileStore) {} @@ -118,22 +114,29 @@ export class FileDumpStore implements DumpStore { }; } + // Both shapes take the same route: files are staged in the registry, written, + // and only then pointed at by a row. A run's NDJSON is one more body file + // under that contract, carried by the response descriptor — which is what + // leaves retention, the sweep and the files-before-row ordering untouched by + // its arrival. async put(keyId: string, record: DumpWriteRecord): Promise { const bucket = hourBucket(record.meta.completedAt); - const requestFileKey = record.request.body.decodedByteLength === 0 + const requestFileKey = record.shape === 'run' || record.request.body.decodedByteLength === 0 ? null : bodyPath(keyId, bucket, record.meta.id, 'req'); - const responseFileKey = record.response.body.type === 'bytes' && record.response.body.body.byteLength === 0 - ? null + const responseFileKey = record.shape === 'run' + ? bodyPath(keyId, bucket, record.meta.id, 'run') : record.response.body.type === 'none' + || (record.response.body.type === 'bytes' && record.response.body.body.byteLength === 0) ? null : bodyPath(keyId, bucket, record.meta.id, 'resp'); // The pre-translation upstream body, when present. Same descriptor shape // as the downstream response body (`{key, type}`), spilled under a // distinct `resp.up` side so the sweep can own it independently. - const upstream = record.response.upstream; + const capture = record.shape === 'edge' ? record.capture : undefined; + const upstream = record.shape === 'edge' ? record.response.upstream : undefined; const upstreamBody = upstream?.body; - const upstreamFileKey = record.capture !== undefined + const upstreamFileKey = capture !== undefined ? bodyPath(keyId, bucket, record.meta.id, 'resp.up') : upstreamBody === undefined ? null @@ -162,27 +165,32 @@ export class FileDumpStore implements DumpStore { .bind(keyId, record.meta.id, Date.now() + SPILLED_FILE_STAGE_GRACE_MS, JSON.stringify(staged)) .run(); } - const requestDescriptor = record.request.body.decodedByteLength === 0 - ? null - : await putPreparedBody(this.files, requestFileKey!, record.request.body); + let requestDescriptor: DumpBodyDescriptor | null = null; let responseDescriptor: DumpBodyDescriptor | null = null; - if (record.response.body.type === 'bytes') { - if (record.response.body.body.byteLength > 0) { - responseDescriptor = await putRawBody(this.files, responseFileKey!, record.response.body.body, 'bytes'); + if (record.shape === 'run') { + responseDescriptor = await putRawBody(this.files, responseFileKey!, record.events, 'run'); + } else { + if (requestFileKey !== null) { + requestDescriptor = await putPreparedBody(this.files, requestFileKey, record.request.body); + } + if (record.response.body.type === 'bytes') { + if (record.response.body.body.byteLength > 0) { + responseDescriptor = await putRawBody(this.files, responseFileKey!, record.response.body.body, 'bytes'); + } + } else if (record.response.body.type === 'stream') { + responseDescriptor = await putRawBody( + this.files, + responseFileKey!, + new TextEncoder().encode(encodeDumpStreamEvents(record.response.body.events, `dump record ${record.meta.id} response events`)), + 'events', + ); } - } else if (record.response.body.type === 'stream') { - responseDescriptor = await putRawBody( - this.files, - responseFileKey!, - new TextEncoder().encode(encodeDumpStreamEvents(record.response.body.events, `dump record ${record.meta.id} response events`)), - 'events', - ); } let upstreamDescriptor: DumpBodyDescriptor | null = null; - if (record.capture !== undefined) { - const envelope = dumpCaptureEnvelopeSchema.parse({ version: 1, capture: record.capture, upstream: upstreamResponseToWire(record.response.upstream) }); + if (capture !== undefined) { + const envelope = dumpCaptureEnvelopeSchema.parse({ version: 1, capture, upstream: upstreamResponseToWire(upstream) }); upstreamDescriptor = await putRawBody(this.files, upstreamFileKey!, new TextEncoder().encode(JSON.stringify(envelope)), 'capture'); } else if (upstreamBody !== undefined) { if (upstreamBody.type === 'bytes') { @@ -211,8 +219,10 @@ export class FileDumpStore implements DumpStore { record.meta.completedAt, record.meta.upstream?.id ?? null, encodePersistedDumpMetadata(record.meta, `dump record ${record.meta.id} metadata`), - encodeDumpHeaders(record.request.headers, `dump record ${record.meta.id} request headers`), - record.response.body.type === 'none' + record.shape === 'run' + ? NO_EDGE_HEADERS + : encodeDumpHeaders(record.request.headers, `dump record ${record.meta.id} request headers`), + record.shape === 'run' || record.response.body.type === 'none' ? null : encodeDumpHeaders(record.response.headers, `dump record ${record.meta.id} response headers`), requestDescriptor === null @@ -277,13 +287,9 @@ export class FileDumpStore implements DumpStore { ...decodePersistedDumpMetadata(row.meta_json, `dump record ${recordId} metadata`), upstream: hydrateUpstream(row), }; - const requestHeaders = decodeDumpHeaders(row.request_headers_json, `dump record ${recordId} request headers`); const requestDescriptor = row.request_body_descriptor === null ? null : decodeDumpBodyDescriptor(row.request_body_descriptor, `dump record ${recordId} request body descriptor`); - const responseHeaders = row.response_headers_json === null - ? null - : decodeDumpHeaders(row.response_headers_json, `dump record ${recordId} response headers`); const responseDescriptor = row.response_body_descriptor === null ? null : decodeDumpBodyDescriptor(row.response_body_descriptor, `dump record ${recordId} response body descriptor`); @@ -291,6 +297,17 @@ export class FileDumpStore implements DumpStore { ? null : decodeDumpBodyDescriptor(row.response_upstream_body_descriptor, `dump record ${recordId} upstream response body descriptor`); + // The body kind is the shape: a run was written as one NDJSON stream and + // has no edge halves to rebuild. + if (responseDescriptor?.type === 'run') { + return { shape: 'run', meta, events: await fetchBody(this.files, responseDescriptor) }; + } + + const requestHeaders = decodeDumpHeaders(row.request_headers_json, `dump record ${recordId} request headers`); + const responseHeaders = row.response_headers_json === null + ? null + : decodeDumpHeaders(row.response_headers_json, `dump record ${recordId} response headers`); + const request: StoredDumpRequest = { method: meta.method, path: meta.path, @@ -320,7 +337,7 @@ export class FileDumpStore implements DumpStore { // Same rehydration rules as the downstream body; absent on native turns // and on records written before the upstream column existed (NULL). let upstream: StoredDumpUpstreamResponse | undefined; - let capture: StoredDumpRecord['capture']; + let capture: StoredDumpEdgeRecord['capture']; if (upstreamDescriptor?.type === 'capture') { const envelope = dumpCaptureEnvelopeSchema.parse(JSON.parse(new TextDecoder().decode(await fetchBody(this.files, upstreamDescriptor)))); capture = envelope.capture; @@ -356,7 +373,7 @@ export class FileDumpStore implements DumpStore { body: responseBody, ...(upstream !== undefined ? { upstream } : {}), }; - return { meta, request, response, ...(capture === undefined ? {} : { capture }) }; + return { shape: 'edge', meta, request, response, ...(capture === undefined ? {} : { capture }) }; } async deleteExpiredBatch(keyId: string, now: number, limit: number): Promise { diff --git a/packages/gateway/src/runtime/log.ts b/packages/gateway/src/runtime/log.ts new file mode 100644 index 0000000000..7805ccbd64 --- /dev/null +++ b/packages/gateway/src/runtime/log.ts @@ -0,0 +1,17 @@ +// The global log sink. A stage's logger writes to the stage's dump record when one is open +// and to this sink always — so a run nobody is recording still reports what went wrong. +// +// The gateway writes to the console and has no level configuration, so the threshold is +// fixed here rather than read from one. Warnings and errors are what a run produces that an +// operator has to see; `debug` and `info` describe one request's progress, and at a line per +// stage per request they would bury the request log this sits alongside. They are still +// recorded in full whenever a dump is open, which is when anyone is reading them. + +import type { Logger } from '@floway-dev/pipeline'; + +export const consoleLogSink: Logger = { + debug: () => {}, + info: () => {}, + warn: (message, fields) => { console.warn(message, fields ?? {}); }, + error: (message, fields) => { console.error(message, fields ?? {}); }, +}; diff --git a/packages/http/__tests__/pipeline_test.ts b/packages/http/__tests__/pipeline_test.ts new file mode 100644 index 0000000000..1321288b7b --- /dev/null +++ b/packages/http/__tests__/pipeline_test.ts @@ -0,0 +1,122 @@ +import { describe, expect, test, vi } from 'vitest'; + +import { isReplayableBody, type Fetcher } from '../src/index.ts'; +import { exchangeResponse, http, type HttpRequestFacts, type HttpResponseFacts, type HttpServices } from '../src/pipeline.ts'; +import { multipartBody, serializeHttpBody, type HttpFile } from '../src/request-content.ts'; +import { compose, move, run, secret, setRelease } from '@floway-dev/pipeline'; + +const request = (overrides: Partial = {}): HttpRequestFacts => move({ + 'request.http.callId': 17, + 'request.http.url': 'https://upstream.test/route', + 'request.http.method': 'POST', + 'request.http.headers': [['Content-Type', 'application/json']], + 'request.http.body': { message: 'hello' }, + 'request.http.encoding': 'json', + ...overrides, +}); + +const execute = (facts: HttpRequestFacts, fetcher: Fetcher, wrapUpstreamCall = async (dispatch: () => Promise): Promise => await dispatch()) => { + const services: HttpServices = { + httpCall: callId => { + expect(callId).toBe(17); + return { fetcher, wrapUpstreamCall, signal: undefined, waitUntil: () => {} }; + }, + }; + return run(compose('httpTest', [http]), facts, services); +}; + +describe('shared HTTP stage', () => { + test('multipart quoting and newline encoding match native Fetch for arbitrary field names and empty filenames', async () => { + const bytes = new Uint8Array([1, 2, 255]); + const entries = [{ name: 'line\nname', value: 'line\nvalue' }, { name: 'upload', value: { name: '', type: '', bytes } }]; + const form = new FormData(); + form.append(entries[0]!.name, entries[0]!.value as string); + form.append('upload', new File([bytes], '', { type: '' })); + const native = new Request('https://upstream.test', { method: 'POST', body: form }); + const boundary = native.headers.get('content-type')!.split('boundary=')[1]!; + const encoded = serializeHttpBody({ boundary, entries }, 'multipart'); + if (!isReplayableBody(encoded)) throw new Error('expected replayable multipart content'); + const actual = await new Response(encoded.open()).arrayBuffer(); + expect(new Uint8Array(actual)).toEqual(new Uint8Array(await native.arrayBuffer())); + expect(actual.byteLength).toBe(encoded.contentLength); + }); + + test('forwards secret values, repeated field lines and immutable JSON content, preserving upstream status and body', async () => { + const item = { content: 'shared nested object' }; + const payload = { input: [item], token: secret('body-secret') }; + const fetcher: Fetcher = async (url, init) => { + expect(url).toBe('https://upstream.test/route'); + expect(init.headers).toEqual([['authorization', 'Bearer header-secret'], ['x-value', 'one'], ['x-value', 'two']]); + if (!isReplayableBody(init.body)) throw new Error('expected a replayable JSON request'); + const bytes = await new Response(init.body.open()).arrayBuffer(); + expect(bytes.byteLength).toBe(init.body.contentLength); + expect(JSON.parse(new TextDecoder().decode(bytes))).toEqual({ input: [item], token: 'body-secret' }); + expect(payload.input[0]).toBe(item); + return new Response('created', { status: 201, statusText: 'Created', headers: { 'x-upstream': 'retained' } }); + }; + const result = await execute(request({ + 'request.http.headers': [['authorization', secret('Bearer header-secret')], ['x-value', 'one'], ['x-value', 'two']], + 'request.http.body': payload, + }), fetcher); + const exchange = result.facts['response.http.exchange']; + if (exchange.type !== 'response') throw new Error('expected upstream response'); + expect(result.facts['response.http.body']).toBe(exchange.body); + expect(exchange.status).toBe(201); + expect(exchange.headers).toContainEqual(['x-upstream', 'retained']); + expect(await exchangeResponse(exchange).text()).toBe('created'); + if (exchange.body !== null) setRelease(exchange.body, async () => {}); + await result.drain(); + }); + + test('multipart request content retains file bytes, names, media type and repeated fields without native file facts', async () => { + const file: HttpFile = { name: 'voice.wav', type: 'audio/wav', bytes: new Uint8Array([0, 1, 2, 255]), lastModified: 42 }; + const body = multipartBody([{ name: 'file', value: file }, { name: 'timestamp[]', value: 'word' }, { name: 'timestamp[]', value: 'segment' }]); + const result = await execute(request({ + 'request.http.headers': [['Content-Type', `multipart/form-data; boundary=${body.boundary}`]], + 'request.http.body': body, + 'request.http.encoding': 'multipart', + }), async (_url, init) => { + if (!isReplayableBody(init.body)) throw new Error('expected replayable multipart content'); + const response = new Response(init.body.open(), { headers: init.headers }); + const form = await response.formData(); + const uploaded = form.get('file') as File; + expect(uploaded.name).toBe('voice.wav'); + expect(uploaded.type).toBe('audio/wav'); + expect(new Uint8Array(await uploaded.arrayBuffer())).toEqual(file.bytes); + expect(form.getAll('timestamp[]')).toEqual(['word', 'segment']); + return new Response(null, { status: 204 }); + }); + expect(result.facts['response.http.body']).toBeNull(); + await result.drain(); + }); + + test('represents actual transport rejection as a value retaining the original error chain', async () => { + const cause = new Error('socket closed'); + const error = new TypeError('fetch failed', { cause }); + const result = await execute(request(), async () => { throw error; }); + expect(result.facts['response.http.exchange']).toEqual({ type: 'transportFailure', error }); + expect(result.facts['response.http.body']).toBeNull(); + await result.drain(); + }); + + test('serialization and timing-wrapper faults propagate as programming errors', async () => { + const fetcher = vi.fn(); + await expect(execute(request({ 'request.http.body': { count: 1n } }), fetcher)).rejects.toThrow(); + const error = new Error('timing wrapper fault'); + await expect(execute(request(), fetcher, async () => { throw error; })).rejects.toBe(error); + expect(fetcher).not.toHaveBeenCalled(); + }); + + test('owns an unread response body immediately and permits the decoder to replace its release action once', async () => { + const cancel = vi.fn(); + const body = new ReadableStream({ cancel }); + const result = await execute(request(), async () => new Response(body)); + const owned = result.facts['response.http.body']; + if (owned === null) throw new Error('expected owned body'); + const finish = vi.fn(async () => { await owned.cancel(); }); + setRelease(owned, finish); + await result.drain(); + expect(finish).toHaveBeenCalledTimes(1); + expect(cancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/http/package.json b/packages/http/package.json index 13ac488ea4..04ef7bbf78 100644 --- a/packages/http/package.json +++ b/packages/http/package.json @@ -4,6 +4,14 @@ "version": "0.0.0", "type": "module", "exports": { + "./pipeline": { + "import": "./src/pipeline.ts", + "types": "./src/pipeline.ts" + }, + "./request-content": { + "import": "./src/request-content.ts", + "types": "./src/request-content.ts" + }, ".": { "import": "./src/index.ts", "types": "./src/index.ts" @@ -13,6 +21,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@discoveryjs/json-ext": "1.1.0", + "@floway-dev/pipeline": "workspace:*", "@noble/hashes": "^2.2.0", "@reclaimprotocol/tls": "0.1.5", "@scure/base": "^2.2.0" diff --git a/packages/http/src/http.ts b/packages/http/src/http.ts new file mode 100644 index 0000000000..0b1efa5f86 --- /dev/null +++ b/packages/http/src/http.ts @@ -0,0 +1,34 @@ +import type { HttpExchange, HttpRequestFacts, HttpResponseFacts, HttpServices } from './pipeline-types.ts'; +import { serializeHttpBody } from './request-content.ts'; +import { takeHttpResponse } from './response.ts'; +import { defineStage, isSecret, move } from '@floway-dev/pipeline'; + +export const http = defineStage({ + name: 'http', + return: { provides: ['response.http.exchange', 'response.http.body'] }, + execute: async (facts, use) => { + const call = use.httpCall(facts['request.http.callId']); + const headers = facts['request.http.headers'].map(([name, value]): [string, string] => + [name, isSecret(value) ? value.reveal() : value]); + const body = serializeHttpBody(facts['request.http.body'], facts['request.http.encoding']); + const init = { + method: facts['request.http.method'], + headers, + body, + signal: call.signal, + }; + new URL(facts['request.http.url']); + new Headers(headers); + const exchange: HttpExchange = await call.wrapUpstreamCall(async () => { + let response: Response; + try { + response = await call.fetcher(facts['request.http.url'], init); + } catch (error) { + await use.log.warn('upstream transport failed', { error }); + return { type: 'transportFailure' as const, error }; + } + return takeHttpResponse(response); + }); + return move({ ...facts, 'response.http.exchange': exchange, 'response.http.body': exchange.type === 'response' ? exchange.body : null }); + }, +}); diff --git a/packages/http/src/pipeline-types.ts b/packages/http/src/pipeline-types.ts new file mode 100644 index 0000000000..2aeda93b06 --- /dev/null +++ b/packages/http/src/pipeline-types.ts @@ -0,0 +1,45 @@ +import type { HttpBody, HttpBodyEncoding } from './request-content.ts'; +import type { Fetcher } from './types.ts'; +import type { Owned, Secret } from '@floway-dev/pipeline'; + +export interface HttpCall { + readonly fetcher: Fetcher; + readonly signal: AbortSignal | undefined; + readonly waitUntil: (work: Promise) => void; + readonly wrapUpstreamCall: (dispatch: () => Promise) => Promise; +} + +export type HttpServices = { + readonly httpCall: (callId: number) => HttpCall; +}; + +export type HttpHeaders = readonly (readonly [string, string | Secret])[]; + +export interface HttpRequestFacts { + 'request.http.callId': number; + 'request.http.url': string; + 'request.http.method': string; + 'request.http.headers': HttpHeaders; + 'request.http.body': HttpBody; + 'request.http.encoding': HttpBodyEncoding; +} + +export interface HttpResponseExchange { + readonly type: 'response'; + readonly status: number; + readonly statusText: string; + readonly headers: readonly (readonly [string, string])[]; + readonly body: (ReadableStream & Owned) | null; +} + +export interface HttpTransportFailure { + readonly type: 'transportFailure'; + readonly error: unknown; +} + +export type HttpExchange = HttpResponseExchange | HttpTransportFailure; + +export interface HttpResponseFacts { + 'response.http.exchange': HttpExchange; + 'response.http.body': (ReadableStream & Owned) | null; +} diff --git a/packages/http/src/pipeline.ts b/packages/http/src/pipeline.ts new file mode 100644 index 0000000000..7dab3cb1be --- /dev/null +++ b/packages/http/src/pipeline.ts @@ -0,0 +1,3 @@ +export { http } from './http.ts'; +export { exchangeResponse, takeHttpResponse } from './response.ts'; +export type { HttpCall, HttpExchange, HttpHeaders, HttpRequestFacts, HttpResponseExchange, HttpResponseFacts, HttpServices, HttpTransportFailure } from './pipeline-types.ts'; diff --git a/packages/http/src/request-content.ts b/packages/http/src/request-content.ts new file mode 100644 index 0000000000..b324f7ca55 --- /dev/null +++ b/packages/http/src/request-content.ts @@ -0,0 +1,92 @@ +import { stringifyChunked } from '@discoveryjs/json-ext'; + +import type { ReplayableBody } from './types.ts'; +import { isSecret } from '@floway-dev/pipeline'; + +export interface HttpFile { + readonly name: string; + readonly type: string; + readonly lastModified?: number; + readonly bytes: Uint8Array; +} + +export interface HttpFormEntry { + readonly name: string; + readonly value: string | HttpFile; +} + +export interface HttpMultipartBody { + readonly boundary: string; + readonly entries: readonly HttpFormEntry[]; +} + +export type HttpBody = object | string | Uint8Array | null; +export type HttpBodyEncoding = 'json' | 'multipart' | 'direct'; + +const encode = (value: string): Uint8Array => new TextEncoder().encode(value); +const quoted = (value: string): string => value.replaceAll('\r', '%0D').replaceAll('\n', '%0A').replaceAll('"', '%22'); +const normalizeNewlines = (value: string): string => value.replace(/\r\n|\r|\n/g, '\r\n'); + +// Preserve Fetch's multipart wire encoding while retaining shared upload bytes. +// https://github.com/nodejs/undici/blob/01a912e49a50c48009ed2639d2a457a6ec26752a/lib/web/fetch/body.js +const multipartChunks = function* (body: HttpMultipartBody): Generator { + for (const { name, value } of body.entries) { + const disposition = `--${body.boundary}\r\nContent-Disposition: form-data; name="${quoted(normalizeNewlines(name))}"`; + if (typeof value === 'string') { + yield encode(`${disposition}\r\n\r\n${normalizeNewlines(value)}\r\n`); + } else { + const filename = value.name.length === 0 ? '' : `; filename="${quoted(value.name)}"`; + yield encode(`${disposition}${filename}\r\nContent-Type: ${value.type || 'application/octet-stream'}\r\n\r\n`); + yield value.bytes; + yield encode('\r\n'); + } + } + yield encode(`--${body.boundary}--\r\n`); +}; + +export const jsonByteChunks = function* (value: object): Generator { + for (const chunk of stringifyChunked(value, (_key: string, field: unknown) => isSecret(field) ? field.reveal() : field)) yield encode(chunk); +}; + +const replayable = (chunks: () => Generator): ReplayableBody => { + let contentLength = 0; + for (const chunk of chunks()) { + contentLength += chunk.byteLength; + if (!Number.isSafeInteger(contentLength)) throw new RangeError('HTTP request content exceeds the supported content length'); + } + return { + contentLength, + open: () => { + const iterator = chunks(); + return new ReadableStream({ + pull(controller) { + const next = iterator.next(); + if (next.done) controller.close(); + else controller.enqueue(next.value); + }, + cancel() { + iterator.return(undefined); + }, + }); + }, + }; +}; + +export const serializeHttpBody = (body: HttpBody, encoding: HttpBodyEncoding): string | ReplayableBody | null => { + if (encoding === 'json') { + if (body === null || typeof body !== 'object' || body instanceof Uint8Array) throw new TypeError('JSON HTTP content must be a parsed object'); + return replayable(() => jsonByteChunks(body)); + } + if (encoding === 'multipart') { + const multipart = body as HttpMultipartBody; + return replayable(() => multipartChunks(multipart)); + } + if (body instanceof Uint8Array) return replayable(function* () { yield body; }); + if (body === null || typeof body === 'string') return body; + throw new TypeError('Direct HTTP content must be text or bytes'); +}; + +export const multipartBody = (entries: readonly HttpFormEntry[]): HttpMultipartBody => ({ + boundary: `floway-${crypto.randomUUID()}`, + entries, +}); diff --git a/packages/http/src/response.ts b/packages/http/src/response.ts new file mode 100644 index 0000000000..8b0a1a3dbd --- /dev/null +++ b/packages/http/src/response.ts @@ -0,0 +1,21 @@ +import type { HttpResponseExchange } from './pipeline-types.ts'; +import { own } from '@floway-dev/pipeline'; + +export const takeHttpResponse = (response: Response): HttpResponseExchange => { + const rawBody = response.body; + const body = rawBody === null ? null : own(rawBody, async () => { await rawBody.cancel(); }); + return { + type: 'response' as const, + status: response.status, + statusText: response.statusText, + headers: [...response.headers], + body, + }; +}; + +export const exchangeResponse = (exchange: HttpResponseExchange): Response => + new Response(exchange.body, { + status: exchange.status, + statusText: exchange.statusText, + headers: exchange.headers.map(([name, value]): [string, string] => [name, value]), + }); diff --git a/packages/pipeline/package.json b/packages/pipeline/package.json index 814f78c88d..303ad70d10 100644 --- a/packages/pipeline/package.json +++ b/packages/pipeline/package.json @@ -7,6 +7,10 @@ ".": { "import": "./src/index.ts", "types": "./src/index.ts" + }, + "./browser": { + "import": "./src/browser.ts", + "types": "./src/browser.ts" } }, "scripts": { diff --git a/packages/pipeline/src/browser.ts b/packages/pipeline/src/browser.ts new file mode 100644 index 0000000000..ff1db939c3 --- /dev/null +++ b/packages/pipeline/src/browser.ts @@ -0,0 +1,10 @@ +// Use `export type *` for types: these exports are erased at runtime. +// Export runtime functions, classes, and constants individually by name. +// Never use `export *` here: new source exports would silently expand +// the browser runtime API without an explicit review. + +export type * from './dump.ts'; +export type * from './read.ts'; + +export { createRunReader } from './read.ts'; +export { encodeRun, streamFact, toNdjson } from './dump.ts'; diff --git a/packages/protocols/__tests__/openai-audio/transcription_test.ts b/packages/protocols/__tests__/openai-audio/transcription_test.ts new file mode 100644 index 0000000000..a7d47725b9 --- /dev/null +++ b/packages/protocols/__tests__/openai-audio/transcription_test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest'; + +import { + parseOpenAIAudioTranscription, + parseOpenAIAudioTranscriptionResponseFormat, + parseOpenAIAudioTranscriptionUsage, + renderOpenAIAudioTranscription, + type OpenAIAudioTranscriptionResponseFormat, +} from '../../src/openai-audio/index.ts'; + +const bytes = (document: string): Uint8Array => new TextEncoder().encode(document); + +/** What a carried rendering says, and a failure where an object rendering came back + * instead — the two are never interchangeable and a test that read one as the other would + * pass on the wrong thing. */ +const carried = (rendered: Record | Uint8Array): string => { + if (!(rendered instanceof Uint8Array)) throw new Error(`expected a carried document, got ${JSON.stringify(rendered)}`); + return new TextDecoder().decode(rendered); +}; + +// Whisper's own writers, which is what `whisper-1` runs: SubRip numbers its cues and always +// writes the hour component, WebVTT opens with its signature and drops the hour component +// below one hour, and both terminate every cue with a blank line. +// https://github.com/openai/whisper/blob/v20250625/whisper/utils.py#L238-L262 +const SRT = '1\n00:00:00,000 --> 00:00:03,320\n The beach was a popular spot.\n\n' + + '2\n00:00:03,320 --> 00:00:08,470\n People were swimming in the ocean.\n\n'; +const VTT = 'WEBVTT\n\n00:00.000 --> 00:03.320\n The beach was a popular spot.\n\n' + + '00:03.320 --> 00:08.470\n People were swimming in the ocean.\n\n'; + +const VERBOSE = { + task: 'transcribe', + language: 'english', + duration: 8.47, + text: ' The beach was a popular spot. People were swimming in the ocean.', + segments: [ + { id: 0, seek: 0, start: 0, end: 3.32, text: ' The beach was a popular spot.', tokens: [50364], temperature: 0, avg_logprob: -0.28, compression_ratio: 1.23, no_speech_prob: 0.009 }, + ], +}; + +const carries = (format: OpenAIAudioTranscriptionResponseFormat, document: string): string => + carried(renderOpenAIAudioTranscription(format, parseOpenAIAudioTranscription(format, bytes(document)))); + +describe('the renderings of a transcription', () => { + it('is the protocol default when the form left the field out', () => { + expect(parseOpenAIAudioTranscriptionResponseFormat(undefined)).toBe('json'); + expect(() => parseOpenAIAudioTranscriptionResponseFormat('yaml')).toThrow('response_format is invalid'); + }); + + // The claim the family rests on: `response_format` travels to the upstream inside the + // form, so the rendering an answer arrives in is the rendering it is written back in, and + // reading then writing has to be the identity on every one of the six. + it.each([ + ['json', JSON.stringify({ text: 'hello' })], + ['verbose_json', JSON.stringify(VERBOSE)], + ['diarized_json', JSON.stringify({ task: 'transcribe', duration: 2, text: 'Agent: hi', segments: [{ type: 'transcript.text.segment', id: 'seg_1', start: 0, end: 2, text: 'hi', speaker: 'agent' }] })], + ['text', ' The beach was a popular spot.\n'], + ['srt', SRT], + ['vtt', VTT], + ] as const)('reads and writes %s back unchanged', (format, document) => { + const rendered = renderOpenAIAudioTranscription(format, parseOpenAIAudioTranscription(format, bytes(document))); + expect(rendered instanceof Uint8Array ? carried(rendered) : JSON.stringify(rendered)).toBe(document); + }); + + // A document is carried and not reproduced, so what the upstream wrote survives every + // convention it did not follow. Each of these is a byte a writer of ours would have + // changed: the terminating blank line Whisper always writes, the ordinal it always starts + // at 1, the line ending it always writes as `\n`, and the millisecond precision it always + // pads to three digits. + it.each([ + ['srt', '1\n00:00:00,000 --> 00:00:03,320\nno terminating blank line'], + ['srt', '7\r\n00:00:00,000 --> 00:00:03,320\r\nnumbered from seven, with CRLF\r\n\r\n'], + ['vtt', 'WEBVTT\n\n00:00.0 --> 00:03.32\nfewer fraction digits than Whisper writes\n\n'], + ['text', 'a transcript with no trailing newline'], + ] as const)('carries a %s document byte for byte, however it was written', (format, document) => { + expect(carries(format, document)).toBe(document); + }); + + // What the canonical value holds is what its rendering carried, and the renderings are not + // equally rich. This is the whole of why a rendering is never derived from a value read + // out of a different one. + it('holds a transcript and its cues where the rendering carried both', () => { + expect(parseOpenAIAudioTranscription('verbose_json', bytes(JSON.stringify(VERBOSE)))).toMatchObject({ + text: VERBOSE.text, + cues: [{ start: 0, end: 3.32, text: ' The beach was a popular spot.' }], + }); + }); + + it('holds no cues where the rendering carried none', () => { + expect(parseOpenAIAudioTranscription('json', bytes(JSON.stringify({ text: 'hello' }))).cues).toBeUndefined(); + }); + + // A subtitle document states the transcript only as its cues' texts, so the transcript on + // the canonical value is rebuilt the way Whisper's own text writer rebuilds it and is not + // claimed to be the string a `text` request would have returned. + // https://github.com/openai/whisper/blob/v20250625/whisper/utils.py#L109-L116 + it('rebuilds the transcript of a subtitle document from its cues', () => { + expect(parseOpenAIAudioTranscription('srt', bytes(SRT)).text).toBe(' The beach was a popular spot.\n People were swimming in the ocean.'); + }); + + it('refuses a body the rendering cannot be read from', () => { + expect(() => parseOpenAIAudioTranscription('json', bytes('{not-json'))).toThrow(SyntaxError); + expect(() => parseOpenAIAudioTranscription('json', bytes(JSON.stringify({ transcript: 'hello' })))).toThrow('text must be a string'); + expect(() => parseOpenAIAudioTranscription('vtt', bytes(SRT))).toThrow('must open with WEBVTT'); + }); + + // The other half of that refusal: a caller that could not read the body still has the body, + // and what it writes back is what arrived. A 2xx nobody could parse is still the upstream's + // answer to the client's request. + it('writes back a body no reading could open', () => { + expect(carried(renderOpenAIAudioTranscription('json', { document: bytes('{not-json') }))).toBe('{not-json'); + }); +}); + +describe('what a transcription says it will be billed for', () => { + it('splits the audio share out of the input tokens the upstream broke down', () => { + expect(parseOpenAIAudioTranscriptionUsage({ + usage: { type: 'tokens', input_tokens: 14, input_token_details: { text_tokens: 10, audio_tokens: 4 }, output_tokens: 101, total_tokens: 115 }, + })).toEqual({ kind: 'tokens', inputTokens: 14, inputAudioTokens: 4, outputTokens: 101 }); + }); + + it('reads a duration report, and whisper\'s top-level duration where there is no report', () => { + expect(parseOpenAIAudioTranscriptionUsage({ usage: { type: 'duration', seconds: 43 } })).toEqual({ kind: 'duration', seconds: 43 }); + expect(parseOpenAIAudioTranscriptionUsage(VERBOSE)).toEqual({ kind: 'duration', seconds: 8.47 }); + }); + + // Nothing was stated, or what was stated is not something this reading names. A metric + // invented after this was written is not a malformed one, and the caller bills neither. + it('is no report where nothing this reading names was stated', () => { + expect(parseOpenAIAudioTranscriptionUsage({ text: 'hello' })).toBeUndefined(); + expect(parseOpenAIAudioTranscriptionUsage({ usage: { type: 'credits', spent: 3 } })).toBeUndefined(); + }); + + // An upstream that reported under a name this reading does know, in a shape it cannot read, + // is a third situation and it says so — which is what lets the caller warn rather than + // record the request as though the upstream had metered nothing. + it('reports a block it names and cannot read, rather than reading it as nothing', () => { + expect(() => parseOpenAIAudioTranscriptionUsage({ usage: { type: 'duration', seconds: 'invalid' } })) + .toThrow('usage.seconds must be a finite non-negative number'); + expect(() => parseOpenAIAudioTranscriptionUsage({ usage: { type: 'tokens', input_tokens: -1, output_tokens: 2 } })) + .toThrow('usage.input_tokens must be a non-negative safe integer'); + expect(() => parseOpenAIAudioTranscriptionUsage({ usage: { type: 'tokens', input_tokens: 4, input_token_details: { audio_tokens: 9 }, output_tokens: 2 } })) + .toThrow('audio_tokens must not exceed usage.input_tokens'); + expect(() => parseOpenAIAudioTranscriptionUsage({ usage: 'billed' })).toThrow('usage must be an object'); + expect(() => parseOpenAIAudioTranscriptionUsage({ duration: 'a while' })).toThrow('duration must be a finite non-negative number'); + }); +}); diff --git a/packages/protocols/__tests__/openai-embeddings/translate_test.ts b/packages/protocols/__tests__/openai-embeddings/translate_test.ts new file mode 100644 index 0000000000..ac8ca9b273 --- /dev/null +++ b/packages/protocols/__tests__/openai-embeddings/translate_test.ts @@ -0,0 +1,169 @@ +import { describe, expect, test } from 'vitest'; + +import { + parseOpenAIEmbeddingsRequest, + parseOpenAIEmbeddingsResponse, + renderOpenAIEmbeddingsResponse, + serializeOpenAIEmbeddingsRequest, +} from '../../src/openai-embeddings/translate.ts'; + +// The float32 little-endian packing of [0.0023064255, -0.009327292, 1, -0.0028842222], +// and the float64 values those bits denote. Produced by Float32Array + Buffer on Node 24, +// which is the encoding both official OpenAI SDKs read a base64 embedding with. +const PACKED = 'ZicXO4DRGLwAAIA/OAU9uw=='; +const VECTOR = [0.002306425478309393, -0.009327292442321777, 1, -0.0028842221945524216]; + +describe('OpenAI Embeddings request ingress', () => { + test('a single text stays a single text, so the wire shape the upstream sees is unchanged', () => { + const parsed = parseOpenAIEmbeddingsRequest({ model: 'text-embedding-3-small', input: 'a sentence' }); + + expect(parsed.model).toBe('text-embedding-3-small'); + expect(parsed.request).toEqual({ input: 'a sentence' }); + expect(serializeOpenAIEmbeddingsRequest(parsed.request)).toEqual({ input: 'a sentence' }); + }); + + test('the four input arms each survive', () => { + const arms = [ + 'one', + ['one', 'two'], + [1212, 318, 257], + [[1212, 318], [257, 1332]], + ]; + for (const input of arms) { + expect(parseOpenAIEmbeddingsRequest({ model: 'm', input }).request.input).toEqual(input); + } + }); + + test('an omitted encoding_format is resolved for the client and left off the wire', () => { + const parsed = parseOpenAIEmbeddingsRequest({ model: 'm', input: 'a' }); + + expect(parsed.encodingFormat).toBe('float'); + expect(parsed.request.encodingFormat).toBeUndefined(); + expect(serializeOpenAIEmbeddingsRequest(parsed.request)).not.toHaveProperty('encoding_format'); + }); + + test('a chosen encoding_format is both what the client reads and what the upstream is asked for', () => { + const parsed = parseOpenAIEmbeddingsRequest({ model: 'm', input: 'a', encoding_format: 'base64', dimensions: 256, user: 'u' }); + + expect(parsed.encodingFormat).toBe('base64'); + expect(serializeOpenAIEmbeddingsRequest(parsed.request)).toEqual({ + input: 'a', + encoding_format: 'base64', + dimensions: 256, + user: 'u', + }); + }); + + test('a field the protocol has no place for is named rather than dropped', () => { + expect(() => parseOpenAIEmbeddingsRequest({ model: 'm', input: 'a', truncate: true, task: 'retrieval' })) + .toThrow('OpenAI Embeddings does not support truncate, task'); + }); + + // An empty array satisfies three of the specification's four input arms at once, so the + // canonical value would be ambiguous even though the JSON is well-formed. + test('an empty input is refused, which is what keeps the parsed arms distinguishable', () => { + expect(() => parseOpenAIEmbeddingsRequest({ model: 'm', input: [] })).toThrow('input must not be empty'); + }); + + test('a mixed array is refused rather than read as whichever arm came first', () => { + expect(() => parseOpenAIEmbeddingsRequest({ model: 'm', input: ['a', 1] })).toThrow('input[1] must be a non-empty string'); + expect(() => parseOpenAIEmbeddingsRequest({ model: 'm', input: [1, 'a'] })).toThrow('input[1] must be an integer'); + }); + + test('a non-integer dimensions is refused', () => { + expect(() => parseOpenAIEmbeddingsRequest({ model: 'm', input: 'a', dimensions: 0 })).toThrow('dimensions must be a positive integer'); + expect(() => parseOpenAIEmbeddingsRequest({ model: 'm', input: 'a', dimensions: 1.5 })).toThrow('dimensions must be an integer'); + }); +}); + +describe('OpenAI Embeddings response egress', () => { + test('a base64 vector is read as the numbers it denotes', () => { + const parsed = parseOpenAIEmbeddingsResponse({ + object: 'list', + model: 'text-embedding-3-small', + data: [{ object: 'embedding', index: 0, embedding: PACKED }], + usage: { prompt_tokens: 8, total_tokens: 8 }, + }, 'm'); + + expect(parsed.embeddings).toEqual([{ index: 0, values: VECTOR }]); + expect(parsed.usage).toEqual({ promptTokens: 8, totalTokens: 8 }); + }); + + test('a truncated base64 vector is refused rather than read as a shorter one', () => { + expect(() => parseOpenAIEmbeddingsResponse({ + model: 'm', + data: [{ index: 0, embedding: PACKED.slice(0, 8) }], + }, 'm')).toThrow('data[0].embedding is not a whole number of float32 values'); + }); + + // The schema marks `model` required and most upstreams send it; Copilot's `/embeddings` + // does not. An answer the gateway understands is one it can write again, so the record is + // completed with the model the request named rather than refused. + test('an upstream that names no model is read as having answered for the requested one', () => { + const parsed = parseOpenAIEmbeddingsResponse( + { object: 'list', data: [{ object: 'embedding', index: 0, embedding: [0.5] }] }, + 'text-embedding-real', + ); + + expect(parsed.model).toBe('text-embedding-real'); + expect(renderOpenAIEmbeddingsResponse('float', parsed)).toMatchObject({ model: 'text-embedding-real' }); + }); + + // A model the upstream *did* name is its own answer, and the request's id never overrides it. + test('a model the upstream named is kept', () => { + expect(parseOpenAIEmbeddingsResponse({ model: 'upstream-id', data: [] }, 'requested-id').model).toBe('upstream-id'); + }); + + // An upstream that reports no usage is a fact the gateway has to be able to carry: the + // billed entity is present with no quantities, which is not the same as reporting zero. + test('a missing usage block leaves the answer intact and reports nothing', () => { + const parsed = parseOpenAIEmbeddingsResponse({ model: 'm', data: [{ index: 0, embedding: [0.5] }] }, 'm'); + + expect(parsed.usage).toBeUndefined(); + expect(renderOpenAIEmbeddingsResponse('float', parsed)).not.toHaveProperty('usage'); + }); + + // The case the encoding split exists for. An OpenAI SDK client asked for base64 without + // saying so and will decode whatever it is handed; an upstream that ignores the field + // and answers with float arrays would otherwise hand it noise. + test('the answer is written in the encoding the client asked for, not the one it arrived in', () => { + const arrived = parseOpenAIEmbeddingsResponse({ model: 'm', data: [{ index: 0, embedding: VECTOR }] }, 'm'); + + expect(renderOpenAIEmbeddingsResponse('base64', arrived)).toMatchObject({ + object: 'list', + data: [{ object: 'embedding', index: 0, embedding: PACKED }], + }); + expect(renderOpenAIEmbeddingsResponse('float', parseOpenAIEmbeddingsResponse({ + model: 'm', + data: [{ index: 0, embedding: PACKED }], + }, 'm'))).toMatchObject({ data: [{ embedding: VECTOR }] }); + }); + + // Every float32 is a float64, so widening loses nothing and narrowing a value that came + // from a float32 gives the same bits back. Holding vectors as numbers is therefore exact + // and not an approximation, however many times a vector crosses the gateway. + test('a vector survives any number of trips through the two encodings', () => { + const once = parseOpenAIEmbeddingsResponse({ model: 'm', data: [{ index: 0, embedding: PACKED }] }, 'm'); + const twice = parseOpenAIEmbeddingsResponse(renderOpenAIEmbeddingsResponse('base64', once), 'm'); + const thrice = parseOpenAIEmbeddingsResponse(renderOpenAIEmbeddingsResponse('base64', twice), 'm'); + + expect(renderOpenAIEmbeddingsResponse('base64', thrice)).toMatchObject({ data: [{ embedding: PACKED }] }); + expect(thrice.embeddings).toEqual(once.embeddings); + }); + + test('object is written by the gateway at both levels rather than repeated back', () => { + const rendered = renderOpenAIEmbeddingsResponse('float', parseOpenAIEmbeddingsResponse({ + object: 'not-a-list', + model: 'm', + data: [{ object: 'not-an-embedding', index: 0, embedding: [0.5] }], + usage: { prompt_tokens: 1, total_tokens: 1 }, + }, 'm')); + + expect(rendered).toEqual({ + object: 'list', + data: [{ object: 'embedding', index: 0, embedding: [0.5] }], + model: 'm', + usage: { prompt_tokens: 1, total_tokens: 1 }, + }); + }); +}); diff --git a/packages/protocols/__tests__/openai-images/request_test.ts b/packages/protocols/__tests__/openai-images/request_test.ts new file mode 100644 index 0000000000..eefcc3d2da --- /dev/null +++ b/packages/protocols/__tests__/openai-images/request_test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from 'vitest'; + +import { parseOpenAIImagesEditsRequest, parseOpenAIImagesGenerationsRequest } from '../../src/openai-images/request.ts'; + +const json = (value: unknown): Uint8Array => new TextEncoder().encode(JSON.stringify(value)); + +const multipart = async (form: FormData): Promise<{ contentType: string; bytes: Uint8Array }> => { + const encoded = new Response(form); + return { + contentType: encoded.headers.get('content-type')!, + bytes: new Uint8Array(await encoded.arrayBuffer()), + }; +}; + +describe('OpenAI Images Generations ingress', () => { + test('routing takes the model and everything else stays as the client wrote it', () => { + const parsed = parseOpenAIImagesGenerationsRequest(json({ + model: 'gpt-image-1', + prompt: 'a shiba in space', + size: '1024x1024', + moderation: 'low', + })); + + expect(parsed.model).toBe('gpt-image-1'); + expect(parsed.request).toEqual({ + operation: 'generations', + parameters: { prompt: 'a shiba in space', size: '1024x1024', moderation: 'low' }, + }); + }); + + test('a body that is not a JSON object, or names no model, is refused with what to fix', () => { + expect(() => parseOpenAIImagesGenerationsRequest(new TextEncoder().encode('not json'))) + .toThrow('OpenAI Images Generations request body must be valid JSON.'); + expect(() => parseOpenAIImagesGenerationsRequest(json(['gpt-image-1']))) + .toThrow('OpenAI Images Generations request body must be an object.'); + expect(() => parseOpenAIImagesGenerationsRequest(json({ prompt: 'hi' }))) + .toThrow('OpenAI Images Generations request body must include a model string.'); + }); +}); + +describe('OpenAI Images Edits ingress', () => { + test('a JSON edit keeps each reference exactly as it arrived', async () => { + const parsed = await parseOpenAIImagesEditsRequest('application/json', json({ + model: 'gpt-image-1', + prompt: 'replace the background', + images: [{ image_url: 'data:image/png;base64,iVBORw0KGgo=' }, { file_id: 'file-source' }], + mask: { file_id: 'file-mask' }, + quality: 'high', + })); + + expect(parsed.model).toBe('gpt-image-1'); + expect(parsed.request).toEqual({ + operation: 'edits', + images: [ + { kind: 'reference', reference: { image_url: 'data:image/png;base64,iVBORw0KGgo=' } }, + { kind: 'reference', reference: { file_id: 'file-source' } }, + ], + mask: { kind: 'reference', reference: { file_id: 'file-mask' } }, + parameters: { prompt: 'replace the background', quality: 'high' }, + }); + }); + + test('a reference naming both ways, or neither, is refused by position', async () => { + await expect(parseOpenAIImagesEditsRequest('application/json', json({ + model: 'gpt-image-1', + images: [{ file_id: 'file-source' }, { image_url: 'https://example.com/a.png', file_id: 'file-source' }], + }))).rejects.toThrow('OpenAI Images Edits images[1] must contain exactly one string field: image_url or file_id.'); + + await expect(parseOpenAIImagesEditsRequest('application/json', json({ model: 'gpt-image-1', prompt: 'hi' }))) + .rejects.toThrow('OpenAI Images Edits request body must include an images array.'); + }); + + test('a multipart edit holds each file as bytes and every other field as text', async () => { + const form = new FormData(); + form.append('model', 'gpt-image-1'); + form.append('prompt', 'replace the sky'); + form.append('n', '2'); + form.append('image[]', new Blob([new Uint8Array([1, 2, 3])], { type: 'image/png' }), 'photo.png'); + form.append('image[]', new Blob([new Uint8Array([4, 5])], { type: 'image/webp' }), 'second.webp'); + form.append('mask', new Blob([new Uint8Array([6])], { type: 'image/png' }), 'mask.png'); + const { contentType, bytes } = await multipart(form); + + const parsed = await parseOpenAIImagesEditsRequest(contentType, bytes); + + expect(parsed.model).toBe('gpt-image-1'); + expect(parsed.request).toEqual({ + operation: 'edits', + images: [ + { kind: 'file', file: { fileName: 'photo.png', mediaType: 'image/png', bytes: new Uint8Array([1, 2, 3]) } }, + { kind: 'file', file: { fileName: 'second.webp', mediaType: 'image/webp', bytes: new Uint8Array([4, 5]) } }, + ], + mask: { kind: 'file', file: { fileName: 'mask.png', mediaType: 'image/png', bytes: new Uint8Array([6]) } }, + // A form field is text, so `n` stays the string the client sent it as. + parameters: { prompt: 'replace the sky', n: '2' }, + }); + }); + + test('a multipart edit naming no model is refused before its fields are read', async () => { + const form = new FormData(); + form.append('prompt', 'replace the sky'); + const { contentType, bytes } = await multipart(form); + + await expect(parseOpenAIImagesEditsRequest(contentType, bytes)) + .rejects.toThrow('OpenAI Images Edits request body must include a model field.'); + }); + + test('an image field carrying text, and a text field carrying a file, are both refused', async () => { + const textImage = new FormData(); + textImage.append('model', 'gpt-image-1'); + textImage.append('image', 'not a file'); + const encodedTextImage = await multipart(textImage); + await expect(parseOpenAIImagesEditsRequest(encodedTextImage.contentType, encodedTextImage.bytes)) + .rejects.toThrow('OpenAI Images Edits image fields must be files.'); + + const fileParameter = new FormData(); + fileParameter.append('model', 'gpt-image-1'); + fileParameter.append('prompt', new Blob([new Uint8Array([1])], { type: 'text/plain' }), 'prompt.txt'); + const encodedFileParameter = await multipart(fileParameter); + await expect(parseOpenAIImagesEditsRequest(encodedFileParameter.contentType, encodedFileParameter.bytes)) + .rejects.toThrow('OpenAI Images Edits prompt field must be text.'); + }); + + test('a body in neither of the two media types the endpoint takes is refused as such', async () => { + await expect(parseOpenAIImagesEditsRequest('text/plain', new TextEncoder().encode('hi'))) + .rejects.toThrow('OpenAI Images Edits request body must use application/json or multipart/form-data.'); + await expect(parseOpenAIImagesEditsRequest(undefined, new TextEncoder().encode('hi'))) + .rejects.toThrow('OpenAI Images Edits request body must use application/json or multipart/form-data.'); + }); +}); diff --git a/packages/protocols/__tests__/openai-images/response_test.ts b/packages/protocols/__tests__/openai-images/response_test.ts new file mode 100644 index 0000000000..d1b590b086 --- /dev/null +++ b/packages/protocols/__tests__/openai-images/response_test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from 'vitest'; + +import { renderErrorEnvelope, upstreamErrorMessage } from '../../src/common/error-envelope.ts'; +import { parseOpenAIImagesResponse, parseOpenAIImagesUsage, renderOpenAIImagesResponse } from '../../src/openai-images/response.ts'; + +describe('OpenAI Images response ingress', () => { + test('the answer is parsed and rendered back whole, fields the gateway does not model included', () => { + const body = { + created: 1713833628, + data: [{ b64_json: 'aGVsbG8=', revised_prompt: 'a shiba, in space' }, { url: 'https://example.com/a.png' }], + background: 'transparent', + output_format: 'png', + }; + + const parsed = parseOpenAIImagesResponse(body); + + expect(parsed.images).toEqual([ + { base64: 'aGVsbG8=', revisedPrompt: 'a shiba, in space' }, + { url: 'https://example.com/a.png' }, + ]); + expect(renderOpenAIImagesResponse(parsed)).toBe(body); + }); + + test('an answer carrying no images at all is read as such, and one that cannot be read is an error', () => { + expect(parseOpenAIImagesResponse({ created: 1 }).images).toEqual([]); + expect(() => parseOpenAIImagesResponse({ data: 'one image' })).toThrow('OpenAI Images response data must be an array'); + expect(() => parseOpenAIImagesResponse({ data: [{ b64_json: 42 }] })).toThrow('OpenAI Images response data[0].b64_json must be a string'); + expect(() => parseOpenAIImagesResponse('an image')).toThrow('OpenAI Images response body must be an object'); + }); +}); + +describe('OpenAI Images usage', () => { + test('what the upstream attributed to images is taken out of the count beside it', () => { + expect(parseOpenAIImagesUsage({ + usage: { + total_tokens: 100, + input_tokens: 50, + output_tokens: 50, + input_tokens_details: { text_tokens: 10, image_tokens: 40 }, + }, + })).toEqual({ inputTokens: 10, inputImageTokens: 40, outputTokens: 50 }); + }); + + test('a count with no detail beside it is the whole count, and a detail saying nothing is as good as absent', () => { + expect(parseOpenAIImagesUsage({ usage: { input_tokens: 10, output_tokens: 50 } })) + .toEqual({ inputTokens: 10, outputTokens: 50 }); + expect(parseOpenAIImagesUsage({ usage: { input_tokens: 10, input_tokens_details: {} } })) + .toEqual({ inputTokens: 10 }); + expect(parseOpenAIImagesUsage({ usage: { output_tokens: 50, output_tokens_details: { image_tokens: 50 } } })) + .toEqual({ outputTokens: 0, outputImageTokens: 50 }); + }); + + // The distinction the return type exists for: an upstream that reported nothing is a + // different fact from one that reported zero, and only the second is a reading. + test('nothing readable is no reading, and a reported zero is one', () => { + expect(parseOpenAIImagesUsage({ data: [] })).toBeUndefined(); + expect(parseOpenAIImagesUsage({ usage: {} })).toBeUndefined(); + expect(parseOpenAIImagesUsage({ usage: { input_tokens: '50' } })).toBeUndefined(); + expect(parseOpenAIImagesUsage({ usage: { input_tokens: 10, input_tokens_details: 'text' } })).toBeUndefined(); + expect(parseOpenAIImagesUsage({ usage: { input_tokens: 0, output_tokens: 0 } })) + .toEqual({ inputTokens: 0, outputTokens: 0 }); + }); +}); + +describe('OpenAI Images failures', () => { + test('an upstream error object is what the client is sent, and anything else becomes the gateway envelope', () => { + const upstream = { error: { message: 'Your request was rejected', type: 'image_generation_user_error', code: 'moderation_blocked' } }; + + expect(upstreamErrorMessage(upstream)).toBe('Your request was rejected'); + expect(renderErrorEnvelope('Your request was rejected', upstream)).toBe(upstream); + + expect(upstreamErrorMessage('upstream is down')).toBeUndefined(); + expect(renderErrorEnvelope('Model gpt-image-1 is not available on any configured upstream.', undefined)) + .toEqual({ error: { message: 'Model gpt-image-1 is not available on any configured upstream.', type: 'api_error' } }); + }); +}); diff --git a/packages/protocols/src/common/error-envelope.ts b/packages/protocols/src/common/error-envelope.ts new file mode 100644 index 0000000000..c92fbd7408 --- /dev/null +++ b/packages/protocols/src/common/error-envelope.ts @@ -0,0 +1,21 @@ +// What a client is sent when a run produced no answer, and how an upstream's own words are +// read out of what it sent. +// +// An upstream that refused in its own words is handed on in them — a client reads the code +// and type inside, and the gateway has nothing truer to say than what the upstream said. This +// is why the test is whether an upstream answered at all rather than which shape it answered +// in: an OpenAI family's error is `{error:{...}}` and a rerank client's is `{message}`, and +// both are already the shape that client reads. +// +// A refusal that never reached an upstream has only the gateway's own words, and those go in +// the envelope every protocol here writes errors in. + +import { isJsonObject } from './json.ts'; + +export const upstreamErrorMessage = (body: unknown): string | undefined => { + if (!isJsonObject(body) || !isJsonObject(body.error)) return undefined; + return typeof body.error.message === 'string' ? body.error.message : undefined; +}; + +export const renderErrorEnvelope = (message: string, upstreamBody?: unknown): Record => + isJsonObject(upstreamBody) ? upstreamBody : { error: { message, type: 'api_error' } }; diff --git a/packages/protocols/src/common/index.ts b/packages/protocols/src/common/index.ts index 84742971aa..5cb483e296 100644 --- a/packages/protocols/src/common/index.ts +++ b/packages/protocols/src/common/index.ts @@ -15,5 +15,6 @@ export * from './parse-sse.ts'; export * from './parse-events.ts'; export * from './preferences.ts'; +export { renderErrorEnvelope, upstreamErrorMessage } from './error-envelope.ts'; export { isJsonObject, isRawNumber, jsonInteger, parseJsonNumber, parseJSONWithRawNumbers, type JsonNumber, type JsonObject } from './json.ts'; export { captureExtras } from './reassemble-extras.ts'; diff --git a/packages/protocols/src/openai-audio/index.ts b/packages/protocols/src/openai-audio/index.ts index 8be6a3e6a9..ee0dc444c5 100644 --- a/packages/protocols/src/openai-audio/index.ts +++ b/packages/protocols/src/openai-audio/index.ts @@ -1,20 +1,28 @@ -// OpenAI-compatible audio transcription stream terminal. The wire remains open -// so provider additions pass through unchanged while the gateway observes only -// the terminal event it needs. -// https://github.com/openai/openai-openapi/blob/db3e53198a66732cfe161339ea63bf36fc0137ad/openapi.yaml#L61780-L61924 +// OpenAI-compatible audio transcription. One request shape — a multipart form — and +// several response renderings, which is the family's whole character: `response_format` +// picks between three JSON objects and three text documents, and `stream` picks a sequence +// of events instead of any of them. -export interface OpenAIAudioTranscriptionStreamEvent { - type: string; - [key: string]: unknown; -} +export type { OpenAIAudioTranscriptionCue, SubtitleDialect } from './subtitles.ts'; +export { parseSubtitleDocument } from './subtitles.ts'; -export interface OpenAIAudioTranscriptionDoneEvent extends OpenAIAudioTranscriptionStreamEvent { - type: 'transcript.text.done'; - text: string; -} +export type { + OpenAIAudioTranscriptionObjectFormat, + OpenAIAudioTranscriptionResponseFormat, + OpenAIAudioTranscriptionUsage, + CanonicalOpenAIAudioTranscription, +} from './transcription.ts'; +export { + OPENAI_AUDIO_TRANSCRIPTION_RESPONSE_FORMATS, + isOpenAIAudioTranscriptionObjectFormat, + parseOpenAIAudioTranscription, + parseOpenAIAudioTranscriptionResponseFormat, + parseOpenAIAudioTranscriptionUsage, + renderOpenAIAudioTranscription, +} from './transcription.ts'; -export const isOpenAIAudioTranscriptionDoneEvent = (event: unknown): event is OpenAIAudioTranscriptionDoneEvent => - typeof event === 'object' - && event !== null - && (event as { type?: unknown }).type === 'transcript.text.done' - && typeof (event as { text?: unknown }).text === 'string'; +export type { OpenAIAudioTranscriptionDoneEvent, OpenAIAudioTranscriptionStreamEvent } from './stream.ts'; +export { + isOpenAIAudioTranscriptionDoneEvent, + parseOpenAIAudioTranscriptionStreamUsage, +} from './stream.ts'; diff --git a/packages/protocols/src/openai-audio/stream.ts b/packages/protocols/src/openai-audio/stream.ts new file mode 100644 index 0000000000..55d73047d6 --- /dev/null +++ b/packages/protocols/src/openai-audio/stream.ts @@ -0,0 +1,30 @@ +// The event stream a transcription answers with when the client asked for one. The stream +// stays open: a provider addition rides through untouched while the gateway names only the +// events it acts on. `transcript.text.delta` is the increment, `transcript.text.done` is +// the terminal one and the only place a streamed transcription states its usage. +// https://github.com/openai/openai-openapi/blob/db3e53198a66732cfe161339ea63bf36fc0137ad/openapi.yaml#L61796-L61917 + +import type { OpenAIAudioTranscriptionUsage } from './transcription.ts'; +import { parseOpenAIAudioTranscriptionUsage } from './transcription.ts'; + +export interface OpenAIAudioTranscriptionStreamEvent { + type: string; + [key: string]: unknown; +} + +export interface OpenAIAudioTranscriptionDoneEvent extends OpenAIAudioTranscriptionStreamEvent { + type: 'transcript.text.done'; + text: string; +} + +export const isOpenAIAudioTranscriptionDoneEvent = (event: unknown): event is OpenAIAudioTranscriptionDoneEvent => + typeof event === 'object' + && event !== null + && (event as { type?: unknown }).type === 'transcript.text.done' + && typeof (event as { text?: unknown }).text === 'string'; + +/** A streamed transcription states its usage once, in the terminal event, in the same shape + * a JSON body states it. */ +export const parseOpenAIAudioTranscriptionStreamUsage = ( + event: OpenAIAudioTranscriptionDoneEvent, +): OpenAIAudioTranscriptionUsage | undefined => parseOpenAIAudioTranscriptionUsage(event); diff --git a/packages/protocols/src/openai-audio/subtitles.ts b/packages/protocols/src/openai-audio/subtitles.ts new file mode 100644 index 0000000000..1938104150 --- /dev/null +++ b/packages/protocols/src/openai-audio/subtitles.ts @@ -0,0 +1,80 @@ +// SubRip and WebVTT, the two renderings of a transcription that are subtitle documents +// rather than JSON. Both carry exactly one thing — timed cues — so reading one is a total +// reading of it. +// +// There is no writer here, and that is the point: a subtitle rendering is carried to the +// client as the bytes the upstream sent, so nothing ever has to reproduce one. What the +// reading is for is the record and the transcript beside it. +// +// The shapes are Whisper's own writers, which is what OpenAI's `whisper-1` runs: SubRip +// numbers its cues from 1, always writes the hour component and separates the milliseconds +// with a comma; WebVTT opens with a `WEBVTT` line, drops the hour component below one hour +// and separates the milliseconds with a period. Both terminate every cue with a blank line. +// Every one of those is a convention rather than a rule, which is why reading is written to +// accept more than Whisper writes. +// https://github.com/openai/whisper/blob/v20250625/whisper/utils.py#L238-L262 + +export interface OpenAIAudioTranscriptionCue { + /** Seconds from the start of the audio. */ + readonly start: number; + readonly end: number; + readonly text: string; +} + +export type SubtitleDialect = 'srt' | 'vtt'; + +const VTT_HEADER = 'WEBVTT'; + +const TIMESTAMP = /^(?:(\d+):)?(\d{1,2}):(\d{1,2})[,.](\d{1,3})$/; + +const readTimestamp = (value: string, dialect: SubtitleDialect): number => { + const parts = TIMESTAMP.exec(value.trim()); + if (parts === null) throw new Error(`${dialect} timestamp is not readable: ${JSON.stringify(value)}`); + const [, hours, minutes, seconds, fraction] = parts; + // A fraction shorter than three digits is still a decimal fraction of a second, so it is + // padded on the right rather than parsed as a millisecond count. + return Number(hours ?? '0') * 3600 + Number(minutes) * 60 + Number(seconds) + Number(fraction.padEnd(3, '0')) / 1000; +}; + +const readCueBlock = (block: readonly string[], dialect: SubtitleDialect): OpenAIAudioTranscriptionCue => { + // SubRip opens a cue with its ordinal and WebVTT with an optional identifier, so the + // timing line is the one holding the arrow and the lines after it are the cue's text. + const timingIndex = block.findIndex(line => line.includes('-->')); + if (timingIndex < 0) throw new Error(`${dialect} cue has no timing line: ${JSON.stringify(block.join('\n'))}`); + const [start, end] = block[timingIndex].split('-->'); + if (end === undefined) throw new Error(`${dialect} cue timing line has no end: ${JSON.stringify(block[timingIndex])}`); + return { + start: readTimestamp(start, dialect), + end: readTimestamp(end, dialect), + text: block.slice(timingIndex + 1).join('\n'), + }; +}; + +export const parseSubtitleDocument = (dialect: SubtitleDialect, document: string): readonly OpenAIAudioTranscriptionCue[] => { + const lines = document.replaceAll('\r\n', '\n').split('\n'); + if (dialect === 'vtt') { + // The signature may carry a byte order mark and may be followed by header metadata on + // the same line. https://www.w3.org/TR/webvtt1/#webvtt-file-body + const signature = lines.shift()?.replace('', '') ?? ''; + if (!signature.startsWith(VTT_HEADER)) { + throw new Error(`WebVTT document must open with ${VTT_HEADER}: ${JSON.stringify(signature)}`); + } + } + + // A blank line ends a cue in both dialects, which is what makes a cue's own text able to + // span several lines. + const blocks: string[][] = []; + let open: string[] | null = null; + for (const line of lines) { + if (line.trim().length === 0) { + open = null; + continue; + } + if (open === null) { + open = []; + blocks.push(open); + } + open.push(line); + } + return blocks.map(block => readCueBlock(block, dialect)); +}; diff --git a/packages/protocols/src/openai-audio/transcription.ts b/packages/protocols/src/openai-audio/transcription.ts new file mode 100644 index 0000000000..3b1edf7f96 --- /dev/null +++ b/packages/protocols/src/openai-audio/transcription.ts @@ -0,0 +1,207 @@ +// The one transcription, and the several documents that are renderings of it. +// +// `response_format` picks a rendering, and it travels to the upstream in the form the client +// sent, so the rendering the client asked for is the rendering the upstream answers in. That +// is what makes one canonical value enough: whichever rendering arrived is the one that goes +// back, and no rendering is ever derived from a value read out of a different one. +// +// A document rendering is **carried**, never written again. `text`, `srt` and `vtt` are +// documents the upstream wrote for the client to read, and re-writing one from what we read +// out of it changes bytes nobody asked to have changed: a cue writer of our own terminates +// the last cue with a blank line the upstream may not have written, renumbers a SubRip cue +// the upstream numbered from something else, and rounds a timestamp to its own precision. So +// the canonical value holds the bytes that arrived and hands them straight back, and what it +// reads out of them is what the record shows and what usage is measured from. +// +// Which is as well, because the renderings are not equally rich. `verbose_json` carries a +// whole transcript and its cues; `json` carries the transcript with usage beside it; `srt` +// and `vtt` carry the cues alone; `text` carries the transcript alone and nothing else at +// all. Those are properties of the renderings, not gaps in the reading. +// https://github.com/openai/openai-openapi/blob/db3e53198a66732cfe161339ea63bf36fc0137ad/openapi.yaml#L714-L745 + +import type { OpenAIAudioTranscriptionCue } from './subtitles.ts'; +import { parseSubtitleDocument } from './subtitles.ts'; + +// `diarized_json` joined the enum with `gpt-4o-transcribe-diarize`; it is a JSON rendering +// and needs nothing of its own here. +// https://github.com/openai/openai-openapi/blob/db3e53198a66732cfe161339ea63bf36fc0137ad/openapi.yaml#L28527-L28545 +export const OPENAI_AUDIO_TRANSCRIPTION_RESPONSE_FORMATS = ['json', 'text', 'srt', 'verbose_json', 'vtt', 'diarized_json'] as const; +export type OpenAIAudioTranscriptionResponseFormat = typeof OPENAI_AUDIO_TRANSCRIPTION_RESPONSE_FORMATS[number]; + +/** The form field is optional and the protocol's own default is `json`. */ +export const parseOpenAIAudioTranscriptionResponseFormat = (value: unknown): OpenAIAudioTranscriptionResponseFormat => { + if (value === undefined) return 'json'; + if (typeof value === 'string' && (OPENAI_AUDIO_TRANSCRIPTION_RESPONSE_FORMATS as readonly string[]).includes(value)) { + return value as OpenAIAudioTranscriptionResponseFormat; + } + throw new Error(`OpenAI Audio Transcriptions response_format is invalid: ${JSON.stringify(value)}`); +}; + +/** A JSON rendering answers with an object; `text`, `srt` and `vtt` answer with a document. + * The split decides how the body is read and written, and it is the whole of what the + * response format decides for a non-streaming answer. */ +export type OpenAIAudioTranscriptionObjectFormat = 'json' | 'verbose_json' | 'diarized_json'; + +export const isOpenAIAudioTranscriptionObjectFormat = ( + format: OpenAIAudioTranscriptionResponseFormat, +): format is OpenAIAudioTranscriptionObjectFormat => + format === 'json' || format === 'verbose_json' || format === 'diarized_json'; + +/** Token-billed and duration-billed models report usage under one discriminated key. A + * transcription answered in `text`, `srt` or `vtt` reports none, because those renderings + * cannot express it. */ +export type OpenAIAudioTranscriptionUsage = + | { + readonly kind: 'tokens'; + readonly inputTokens: number; + /** Split out of `inputTokens` when the upstream broke it down, because audio input is + * priced apart from text input. */ + readonly inputAudioTokens?: number; + readonly outputTokens: number; + } + | { readonly kind: 'duration'; readonly seconds: number }; + +export interface CanonicalOpenAIAudioTranscription { + /** What the upstream sent, byte for byte. Every rendering but the objects is written back + * from this, and so is a body the reading below could not open. + * + * Bytes rather than a string, because a decode is a reading like any other: a document + * under a charset nothing here asked about survives being carried and does not survive + * being decoded and encoded again. */ + readonly document: Uint8Array; + /** The whole transcript, as the rendering stated it. A subtitle document does not carry + * one, so for `srt` and `vtt` this is the cues' texts joined by newlines — which is what + * Whisper's own text writer produces from the same result, and is not the string a `text` + * request would have returned. Absent on a document the reading could not open. + * https://github.com/openai/whisper/blob/v20250625/whisper/utils.py#L109-L116 */ + readonly text?: string; + /** The timed cues. This is the whole of `srt` and `vtt`; `verbose_json` and + * `diarized_json` carry the same thing as `segments`. */ + readonly cues?: readonly OpenAIAudioTranscriptionCue[]; + /** The upstream's own object, kept whole for the renderings that are objects. A JSON + * answer is re-serialized from this, so a field this type does not name — a segment's + * `avg_logprob`, a `logprobs` array, a `speaker` label — reaches the client unchanged, + * and it is also what usage is read out of. */ + readonly raw?: Record; +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const isFiniteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); + +const seconds = (value: unknown, where: string): number => { + if (!isFiniteNumber(value) || value < 0) throw new Error(`OpenAI Audio Transcriptions ${where} must be a finite non-negative number`); + return value; +}; + +const count = (value: unknown, where: string): number => { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`OpenAI Audio Transcriptions ${where} must be a non-negative safe integer`); + } + return value; +}; + +/** + * What the upstream said it will bill for. + * + * `undefined` is "nothing this reading recognizes was stated": no report at all, or one under + * a discriminator this protocol does not name — a metric invented after this was written is + * not a malformed one. A report that *is* one of the two and cannot be read is the other + * situation entirely, and it throws, so the caller can say so rather than bill an upstream + * that did meter as though it had not. + * + * Whisper's `verbose_json` predates the `usage` block and states the same duration at the top + * level, so that is read where no block is present. + * https://github.com/openai/openai-openapi/blob/db3e53198a66732cfe161339ea63bf36fc0137ad/openapi.yaml#L36513-L36545 + * https://github.com/openai/openai-openapi/blob/db3e53198a66732cfe161339ea63bf36fc0137ad/openapi.yaml#L61968-L62051 + */ +export const parseOpenAIAudioTranscriptionUsage = (value: unknown): OpenAIAudioTranscriptionUsage | undefined => { + if (!isRecord(value)) return undefined; + if (value.usage === undefined) { + if (value.duration === undefined) return undefined; + return { kind: 'duration', seconds: seconds(value.duration, 'duration') }; + } + const usage = value.usage; + if (!isRecord(usage)) throw new Error('OpenAI Audio Transcriptions usage must be an object'); + if (usage.type === 'duration') return { kind: 'duration', seconds: seconds(usage.seconds, 'usage.seconds') }; + if (usage.type !== 'tokens') return undefined; + + const inputTokens = count(usage.input_tokens, 'usage.input_tokens'); + const outputTokens = count(usage.output_tokens, 'usage.output_tokens'); + const details = usage.input_token_details; + if (details !== undefined && !isRecord(details)) throw new Error('OpenAI Audio Transcriptions usage.input_token_details must be an object'); + const audioTokens = details?.audio_tokens === undefined + ? undefined + : count(details.audio_tokens, 'usage.input_token_details.audio_tokens'); + if (audioTokens !== undefined && audioTokens > inputTokens) { + throw new Error('OpenAI Audio Transcriptions usage.input_token_details.audio_tokens must not exceed usage.input_tokens'); + } + return { + kind: 'tokens', + inputTokens, + ...(audioTokens === undefined ? {} : { inputAudioTokens: audioTokens }), + outputTokens, + }; +}; + +const requiredText = (value: unknown, where: string): string => { + if (typeof value !== 'string') throw new Error(`OpenAI Audio Transcriptions ${where} must be a string`); + return value; +}; + +/** `verbose_json` and `diarized_json` both time their segments with seconds and text; the + * fields they do not share stay in `raw` and reach the client through it. */ +const objectCues = (value: Record): readonly OpenAIAudioTranscriptionCue[] | undefined => { + if (!Array.isArray(value.segments)) return undefined; + return value.segments.map((segment: unknown, index) => { + if (!isRecord(segment) || !isFiniteNumber(segment.start) || !isFiniteNumber(segment.end)) { + throw new Error(`OpenAI Audio Transcriptions segment ${index} must carry numeric start and end`); + } + return { start: segment.start, end: segment.end, text: requiredText(segment.text, `segment ${index} text`) }; + }); +}; + +/** + * The one reading, over the bytes the upstream sent. + * + * It throws on a body it cannot open, and a caller that carries the document rather than + * serializing one from what it read answers anyway: what a failed reading costs is the + * transcript in the record and the usage measured beside it, never the answer. + */ +export const parseOpenAIAudioTranscription = ( + format: OpenAIAudioTranscriptionResponseFormat, + document: Uint8Array, +): CanonicalOpenAIAudioTranscription => { + const decoded = new TextDecoder().decode(document); + if (isOpenAIAudioTranscriptionObjectFormat(format)) { + const body: unknown = JSON.parse(decoded); + if (!isRecord(body)) throw new Error('OpenAI Audio Transcriptions response body must be an object'); + const cues = objectCues(body); + return { + document, + text: requiredText(body.text, 'text'), + ...(cues === undefined ? {} : { cues }), + raw: body, + }; + } + if (format === 'text') return { document, text: decoded }; + const cues = parseSubtitleDocument(format, decoded); + return { document, text: cues.map(cue => cue.text).join('\n'), cues }; +}; + +/** + * The one writing, back into the rendering the client asked for. + * + * An object rendering is the upstream's own object re-serialized, which is what keeps a field + * this protocol does not name on its way to the client. Everything else is the document that + * arrived, handed back untouched — and so is an object rendering whose body the reading could + * not open, because bytes nobody could read are still the answer the upstream gave. + */ +export const renderOpenAIAudioTranscription = ( + format: OpenAIAudioTranscriptionResponseFormat, + transcription: CanonicalOpenAIAudioTranscription, +): Record | Uint8Array => + isOpenAIAudioTranscriptionObjectFormat(format) && transcription.raw !== undefined + ? transcription.raw + : transcription.document; diff --git a/packages/protocols/src/openai-completions/index.ts b/packages/protocols/src/openai-completions/index.ts index 51d6c292d7..f2741f86c2 100644 --- a/packages/protocols/src/openai-completions/index.ts +++ b/packages/protocols/src/openai-completions/index.ts @@ -1,15 +1,53 @@ -// OpenAI text-completion protocol (POST /v1/completions). Floway runs -// this endpoint as a passthrough, so this module declares the -// protocol's wire shape rather than a read-time DTO: the gateway's -// /completions handler parses the inbound body through its own local -// shape and reads `model` / `stream` / `stream_options` directly. Only -// `model` is read structurally by downstream code (the provider -// interface accepts `Omit` and forwards -// the rest unchanged); every other field flows through via the index -// signature. +// OpenAI text completions (`POST /v1/completions`). The gateway carries this protocol end to +// end: what a client sends is parsed into `OpenAICompletionsPayload`, what an upstream answers +// is parsed into an `OpenAICompletionsResult` or a frame stream, and what the client receives +// is serialized back from those. No body is forwarded verbatim, so the shapes here are the +// protocol's own rather than a convenience view over bytes in flight. +// +// Request and response follow OpenAI's `CreateCompletionRequest` and +// `CreateCompletionResponse`, and the specification's own note on the latter is why there is +// no separate streaming envelope type below the chunk level — "both the streamed and the +// non-streamed response objects share the same shape (unlike the chat endpoint)": +// https://github.com/openai/openai-openapi/blob/2186421dca0cca7c1e67caa7739005e8b1ccc4dd/openapi.yaml#L33793-L34030 +// https://github.com/openai/openai-openapi/blob/2186421dca0cca7c1e67caa7739005e8b1ccc4dd/openapi.yaml#L34031-L34149 +// `model` is the one field the gateway itself reads structurally on the way in — routing is +// what needs it — and `stream` / `stream_options` are what decide the shape of the answer. +// The rest is named because the protocol names it, and is optional here because the upstream +// is what accepts or refuses a request: the specification also requires `prompt`, and an +// opinion about that held here would have to be kept in step with every OpenAI-compatible +// upstream we route to. The index signature carries a vendor extension through to the +// upstream that understands it. export interface OpenAICompletionsPayload { model: string; + prompt?: string | readonly string[] | readonly number[] | readonly (readonly number[])[] | null; + best_of?: number | null; + echo?: boolean | null; + frequency_penalty?: number | null; + logit_bias?: Record | null; + logprobs?: number | null; + max_tokens?: number | null; + n?: number | null; + presence_penalty?: number | null; + seed?: number | null; + stop?: string | readonly string[] | null; + stream?: boolean | null; + stream_options?: OpenAICompletionsStreamOptions | null; + suffix?: string | null; + temperature?: number | null; + top_p?: number | null; + user?: string | null; + [key: string]: unknown; +} + +// `/v1/completions` shares OpenAI's `ChatCompletionStreamOptions` with `/v1/chat/completions`, +// so `include_obfuscation` sits beside `include_usage` here as well: +// https://github.com/openai/openai-openapi/blob/2186421dca0cca7c1e67caa7739005e8b1ccc4dd/openapi.yaml#L31764-L31810 +// The index signature is what lets the gateway turn `include_usage` on without dropping the +// siblings a client sent. +export interface OpenAICompletionsStreamOptions { + include_usage?: boolean; + include_obfuscation?: boolean; [key: string]: unknown; } @@ -27,16 +65,18 @@ interface OpenAICompletionsChoiceStreaming { logprobs?: unknown; } +// OpenAI's `CompletionUsage`, which `/v1/completions` reuses verbatim from +// `/v1/chat/completions`: +// https://github.com/openai/openai-openapi/blob/2186421dca0cca7c1e67caa7739005e8b1ccc4dd/openapi.yaml#L32210-L32283 +// The optional prompt-cache split is the part upstreams disagree about: OpenAI's own text +// models do not populate it today, while vLLM, llama.cpp, Fireworks, OpenRouter and xAI Grok +// all emit `cached_tokens` here on `/v1/completions`, and Azure mirrors the schema. Billing +// reads the split through `openAICacheTokensFromUsage`, which also knows the field names the +// wilder forks use, so what is named here is the schema rather than the union of the wild. export interface OpenAICompletionsUsage { prompt_tokens: number; completion_tokens: number; total_tokens: number; - // OpenAI's CompletionUsage schema (which /v1/completions reuses verbatim - // from /v1/chat/completions) carries an optional prompt-cache split. - // OpenAI's own text models do not populate it today, but vLLM, llama.cpp, - // Fireworks, OpenRouter, and xAI Grok all emit `cached_tokens` here on - // /v1/completions, and Azure mirrors the schema. Floway extracts it when - // present so billing metrics match what the upstream actually reported. prompt_tokens_details?: { cached_tokens?: number }; } @@ -62,15 +102,23 @@ export interface OpenAICompletionsChoice { logprobs?: unknown; } +// `service_tier` is not in the specification's response schema — it belongs to the chat +// endpoint — but a vLLM fork was observed emitting it on the non-streaming +// `/v1/completions` body (null, on a Zhipu/GLM build) and billing reads it as the pricing +// tier when it is there, so it is named rather than left to the index signature. export interface OpenAICompletionsResult { id: string; object: 'text_completion'; created: number; model: string; choices: OpenAICompletionsChoice[]; + service_tier?: string | null; usage?: OpenAICompletionsUsage; system_fingerprint?: string; + [key: string]: unknown; } +export { parseOpenAICompletionsPayload, parseOpenAICompletionsResult } from './parse.ts'; export { reassembleOpenAICompletionsEvents } from './reassemble.ts'; +export { parseOpenAICompletionsStream, type ParseOpenAICompletionsStreamOptions } from './stream.ts'; export { openaiCompletionsProtocolFrameToSSEFrame } from './to-sse.ts'; diff --git a/packages/protocols/src/openai-completions/parse.ts b/packages/protocols/src/openai-completions/parse.ts new file mode 100644 index 0000000000..5837d1f7eb --- /dev/null +++ b/packages/protocols/src/openai-completions/parse.ts @@ -0,0 +1,22 @@ +import type { OpenAICompletionsPayload, OpenAICompletionsResult } from './index.ts'; +import { isJsonObject } from '../common/json.ts'; + +// The two boundaries where a `/v1/completions` body becomes a value. Both check what the +// gateway itself depends on and nothing beyond it: the request must name a model, because +// routing reads it, and either body must be a JSON object, because everything downstream +// addresses it as one. Which fields an upstream accepts and which it returns is the +// upstream's to decide, and a gateway opinion about the rest would have to be kept in step +// with every OpenAI-compatible implementation we route to. + +export const parseOpenAICompletionsPayload = (value: unknown): OpenAICompletionsPayload => { + if (!isJsonObject(value)) throw new Error('OpenAI Completions request body must be an object.'); + if (typeof value.model !== 'string' || value.model.length === 0) { + throw new Error('OpenAI Completions request body must include a model string.'); + } + return value as OpenAICompletionsPayload; +}; + +export const parseOpenAICompletionsResult = (value: unknown): OpenAICompletionsResult => { + if (!isJsonObject(value)) throw new Error('OpenAI Completions response body must be an object.'); + return value as OpenAICompletionsResult; +}; diff --git a/packages/protocols/src/openai-completions/stream.ts b/packages/protocols/src/openai-completions/stream.ts new file mode 100644 index 0000000000..179b5f228a --- /dev/null +++ b/packages/protocols/src/openai-completions/stream.ts @@ -0,0 +1,26 @@ +import type { OpenAICompletionsStreamEvent } from './index.ts'; +import { parseTargetStreamFrames } from '../common/parse-events.ts'; +import { parseSSEStream } from '../common/parse-sse.ts'; +import { doneFrame, eventFrame, type ProtocolFrame } from '../common/sse.ts'; + +export interface ParseOpenAICompletionsStreamOptions { + signal?: AbortSignal; +} + +// The upstream's SSE body as protocol frames. Transport framing ends here: what comes out +// carries `text_completion` chunks and a terminal frame, and nothing that reads it knows how +// those arrived. +export const parseOpenAICompletionsStream = ( + body: ReadableStream, + options: ParseOpenAICompletionsStreamOptions = {}, +): AsyncGenerator> => (async function* () { + for await (const frame of parseTargetStreamFrames(parseSSEStream(body, options), { + protocol: 'OpenAI Completions', + })) { + if (frame.type === 'done') { + yield doneFrame(); + return; + } + yield eventFrame(frame.data); + } +})(); diff --git a/packages/protocols/src/openai-embeddings/index.ts b/packages/protocols/src/openai-embeddings/index.ts index a82596b253..3555398383 100644 --- a/packages/protocols/src/openai-embeddings/index.ts +++ b/packages/protocols/src/openai-embeddings/index.ts @@ -1,5 +1,100 @@ +// OpenAI's `/v1/embeddings` contract, and the canonical form the gateway holds it in. +// Specification: https://github.com/openai/openai-openapi/blob/2186421dca0cca7c1e67caa7739005e8b1ccc4dd/openapi.yaml#L34214-L34331 +// +// There is one OpenAI Embeddings protocol, so the canonical form and the wire differ in one +// substantive place: `encoding_format` decides how a vector is *written*, and a vector is +// the same vector either way — so the canonical response holds numbers, and the encoding +// is a rendering decision the edge makes from what the client asked for. +// +// That divergence is load-bearing rather than tidy. Both official OpenAI SDKs send +// `encoding_format: base64` when the caller did not choose one, so base64 is the common +// case on the wire and not an exotic one: +// https://github.com/openai/openai-python/blob/10ee3f0da2ac6f93345c1204bd7bb1a2faa79ff2/src/openai/resources/embeddings.py#L111-L112 +// https://github.com/openai/openai-node/blob/cc7dbfa9b9dd6fe0ff72141e9c0c3d82b18ba9aa/src/resources/embeddings.ts#L36-L42 +// An upstream that ignores the field and answers with float arrays hands such a client a +// body it will decode as base64 and turn into noise. Parsing whatever arrived and +// rendering what the client asked for is what closes that. + +/** How a vector is written on the wire. Under `base64` an embedding is one string, not an + * array of numbers. + * https://github.com/openai/openai-openapi/blob/2186421dca0cca7c1e67caa7739005e8b1ccc4dd/openapi.yaml#L34272-L34280 */ +export type OpenAIEmbeddingsEncodingFormat = 'float' | 'base64'; + +/** One text, a batch of texts, one pre-tokenized text, or a batch of those — the + * specification's own four-armed `oneOf`: + * https://github.com/openai/openai-openapi/blob/2186421dca0cca7c1e67caa7739005e8b1ccc4dd/openapi.yaml#L34218-L34254 + * + * The arms are told apart by reading an element, which works only because every array arm + * is `minItems: 1` there — an empty array would belong to three of them at once. Parsing + * rejects it, so a value of this type is always unambiguous. */ +export type OpenAIEmbeddingsInput = string | readonly string[] | readonly number[] | readonly (readonly number[])[]; + +export interface CanonicalOpenAIEmbeddingsRequest { + input: OpenAIEmbeddingsInput; + /** What the upstream is asked for, absent included — a client that did not ask must not + * grow a field on the wire it never sent. */ + encodingFormat?: OpenAIEmbeddingsEncodingFormat; + /** Only `text-embedding-3` and later models honour it + * (https://github.com/openai/openai-openapi/blob/2186421dca0cca7c1e67caa7739005e8b1ccc4dd/openapi.yaml#L34281-L34286), + * and which models those are is the upstream's knowledge and not the catalog's — so it + * travels to whichever upstream is chosen and that upstream answers for it. */ + dimensions?: number; + /** https://github.com/openai/openai-openapi/blob/2186421dca0cca7c1e67caa7739005e8b1ccc4dd/openapi.yaml#L34287-L34293 */ + user?: string; +} + +export interface ParsedOpenAIEmbeddingsRequest { + model: string; + /** What the client will be able to read: `encoding_format` resolved against the + * protocol's own default of `float` + * (https://github.com/openai/openai-openapi/blob/2186421dca0cca7c1e67caa7739005e8b1ccc4dd/openapi.yaml#L34276). + * Concrete, because the answer has to be written in one encoding or the other — and + * distinct from the request's own field, which stays absent when the client omitted it. */ + encodingFormat: OpenAIEmbeddingsEncodingFormat; + request: CanonicalOpenAIEmbeddingsRequest; +} + +export interface CanonicalOpenAIEmbedding { + /** Which input this vector answers, carried by the specification as a field rather than + * left to array position: + * https://github.com/openai/openai-openapi/blob/2186421dca0cca7c1e67caa7739005e8b1ccc4dd/openapi.yaml#L38168-L38170 */ + index: number; + values: readonly number[]; +} + +export interface CanonicalOpenAIEmbeddingsUsage { + promptTokens: number; + totalTokens: number; +} + +export interface CanonicalOpenAIEmbeddingsResponse { + /** What the upstream says it generated with, which need not be the id the client + * addressed — an upstream is free to answer under a dated or internal name. */ + model: string; + embeddings: readonly CanonicalOpenAIEmbedding[]; + /** The specification requires it + * (https://github.com/openai/openai-openapi/blob/2186421dca0cca7c1e67caa7739005e8b1ccc4dd/openapi.yaml#L34327-L34331) + * and it is optional here regardless, because an upstream that reports nothing is a + * situation the gateway has to be able to state: the billed entity is then present with + * no quantities, which is a different fact from reporting zero. Rejecting the body + * instead would turn an answered call into a 502 and throw away the vectors it + * returned. */ + usage?: CanonicalOpenAIEmbeddingsUsage; +} + +/** The request as it goes out. `model` is the upstream's own id and the provider + * substitutes it, so what the gateway hands a provider is everything but that. */ export interface OpenAIEmbeddingsPayload { model: string; - input?: unknown; - [key: string]: unknown; + input: OpenAIEmbeddingsInput; + encoding_format?: OpenAIEmbeddingsEncodingFormat; + dimensions?: number; + user?: string; } + +export { + parseOpenAIEmbeddingsRequest, + parseOpenAIEmbeddingsResponse, + renderOpenAIEmbeddingsResponse, + serializeOpenAIEmbeddingsRequest, +} from './translate.ts'; diff --git a/packages/protocols/src/openai-embeddings/translate.ts b/packages/protocols/src/openai-embeddings/translate.ts new file mode 100644 index 0000000000..f524d8b8e0 --- /dev/null +++ b/packages/protocols/src/openai-embeddings/translate.ts @@ -0,0 +1,199 @@ +import type { + CanonicalOpenAIEmbedding, + CanonicalOpenAIEmbeddingsRequest, + CanonicalOpenAIEmbeddingsResponse, + CanonicalOpenAIEmbeddingsUsage, + OpenAIEmbeddingsEncodingFormat, + OpenAIEmbeddingsInput, + OpenAIEmbeddingsPayload, + ParsedOpenAIEmbeddingsRequest, +} from './index.ts'; +import { decodeForgivingBase64, encodeBase64 } from '../common/base-encoding.ts'; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const requiredString = (value: unknown, field: string): string => { + if (typeof value !== 'string' || value.length === 0) throw new Error(`${field} must be a non-empty string`); + return value; +}; + +const optionalString = (value: unknown, field: string): string | undefined => { + if (value === undefined) return undefined; + if (typeof value !== 'string') throw new Error(`${field} must be a string`); + return value; +}; + +const requiredInteger = (value: unknown, field: string): number => { + if (typeof value !== 'number' || !Number.isInteger(value)) throw new Error(`${field} must be an integer`); + return value; +}; + +const optionalPositiveInteger = (value: unknown, field: string): number | undefined => { + if (value === undefined) return undefined; + const integer = requiredInteger(value, field); + if (integer < 1) throw new Error(`${field} must be a positive integer`); + return integer; +}; + +const parseEncodingFormat = (value: unknown): OpenAIEmbeddingsEncodingFormat | undefined => { + if (value === undefined) return undefined; + if (value !== 'float' && value !== 'base64') throw new Error('encoding_format must be float or base64'); + return value; +}; + +const parseTokenIds = (value: readonly unknown[], field: string): readonly number[] => { + if (value.length === 0) throw new Error(`${field} must not be empty`); + return value.map((token, index) => requiredInteger(token, `${field}[${index}]`)); +}; + +const parseInput = (value: unknown): OpenAIEmbeddingsInput => { + if (typeof value === 'string') return value; + if (!Array.isArray(value)) throw new Error('input must be a string or an array'); + if (value.length === 0) throw new Error('input must not be empty'); + const head: unknown = value[0]; + if (typeof head === 'string') return value.map((text, index) => requiredString(text, `input[${index}]`)); + if (!Array.isArray(head)) return parseTokenIds(value as readonly unknown[], 'input'); + return value.map((tokens, index) => { + if (!Array.isArray(tokens)) throw new Error(`input[${index}] must be an array of integers`); + return parseTokenIds(tokens as readonly unknown[], `input[${index}]`); + }); +}; + +// The request schema is `additionalProperties: false` +// (https://github.com/openai/openai-openapi/blob/2186421dca0cca7c1e67caa7739005e8b1ccc4dd/openapi.yaml#L34216), +// so the protocol has no extension point and a field outside this set is one the gateway +// cannot carry. Naming it beats dropping it silently and beats guessing what it meant. The +// two gateways compared against draw the same line: LiteLLM's embeddings surface is +// `user` / `encoding_format` / `dimensions`, and anything else raises unless the operator +// opts into dropping — +// https://github.com/BerriAI/litellm/blob/bc6e7df05b018eefe6c7293790ca3f4de38709ac/litellm/utils.py#L3259-L3273 +// — while copilot-api's request type is narrower still, `input` and `model` alone: +// https://github.com/ericc-ch/copilot-api/blob/0ea08febdd7e3e055b03dd298bf57e669500b5c1/src/services/copilot/create-embeddings.ts#L19-L22 +const OPENAI_EMBEDDINGS_REQUEST_FIELDS = ['model', 'input', 'encoding_format', 'dimensions', 'user']; + +export const parseOpenAIEmbeddingsRequest = (value: unknown): ParsedOpenAIEmbeddingsRequest => { + if (!isRecord(value)) throw new Error('OpenAI Embeddings request body must be an object'); + const unsupported = Object.keys(value).filter(field => !OPENAI_EMBEDDINGS_REQUEST_FIELDS.includes(field)); + if (unsupported.length > 0) throw new Error(`OpenAI Embeddings does not support ${unsupported.join(', ')}`); + + const encodingFormat = parseEncodingFormat(value.encoding_format); + const dimensions = optionalPositiveInteger(value.dimensions, 'dimensions'); + const user = optionalString(value.user, 'user'); + return { + model: requiredString(value.model, 'model'), + encodingFormat: encodingFormat ?? 'float', + request: { + input: parseInput(value.input), + ...(encodingFormat === undefined ? {} : { encodingFormat }), + ...(dimensions === undefined ? {} : { dimensions }), + ...(user === undefined ? {} : { user }), + }, + }; +}; + +export const serializeOpenAIEmbeddingsRequest = (request: CanonicalOpenAIEmbeddingsRequest): Omit => ({ + input: request.input, + ...(request.encodingFormat === undefined ? {} : { encoding_format: request.encodingFormat }), + ...(request.dimensions === undefined ? {} : { dimensions: request.dimensions }), + ...(request.user === undefined ? {} : { user: request.user }), +}); + +// A base64 embedding is the vector's float32 elements packed little-endian. Both official +// SDKs establish that by reading the decoded bytes straight into a native float32 view — +// `array.array('f', ...)` and `Float32Array`, each host-endian and each correct only +// because every platform they run on is: +// https://github.com/openai/openai-python/blob/10ee3f0da2ac6f93345c1204bd7bb1a2faa79ff2/src/openai/resources/embeddings.py#L122-L131 +// https://github.com/openai/openai-node/blob/cc7dbfa9b9dd6fe0ff72141e9c0c3d82b18ba9aa/src/internal/utils/base64.ts#L45-L52 +// `DataView` states the byte order rather than inheriting it. +// +// Holding a vector as numbers is exact and not an approximation: every float32 is a +// float64, so widening loses nothing and narrowing a value that came from a float32 gives +// the same bits back. A vector survives any number of trips through this pair. +const FLOAT32_BYTES = 4; + +const parseEmbedding = (value: unknown, field: string): readonly number[] => { + if (Array.isArray(value)) { + return value.map((element, index) => { + if (typeof element !== 'number') throw new Error(`${field}[${index}] must be a number`); + return element; + }); + } + if (typeof value !== 'string') throw new Error(`${field} must be an array of numbers or a base64 string`); + const bytes = decodeForgivingBase64(value); + if (bytes.length % FLOAT32_BYTES !== 0) throw new Error(`${field} is not a whole number of float32 values`); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return Array.from({ length: bytes.length / FLOAT32_BYTES }, (_, index) => view.getFloat32(index * FLOAT32_BYTES, true)); +}; + +const renderEmbedding = (values: readonly number[]): string => { + const bytes = new Uint8Array(values.length * FLOAT32_BYTES); + const view = new DataView(bytes.buffer); + values.forEach((value, index) => view.setFloat32(index * FLOAT32_BYTES, value, true)); + return encodeBase64(bytes); +}; + +const parseUsage = (value: unknown): CanonicalOpenAIEmbeddingsUsage | undefined => { + if (value === undefined || value === null) return undefined; + if (!isRecord(value)) throw new Error('usage must be an object'); + return { + promptTokens: requiredInteger(value.prompt_tokens, 'usage.prompt_tokens'), + totalTokens: requiredInteger(value.total_tokens, 'usage.total_tokens'), + }; +}; + +/** + * Reads an upstream's answer into the canonical form. + * + * `requestedModel` completes the record when the upstream did not name one. The schema marks + * `model` required — + * https://github.com/openai/openai-openapi/blob/2186421dca0cca7c1e67caa7739005e8b1ccc4dd/openapi.yaml#L34327-L34331 + * — and we have observed Copilot's `/embeddings` answering without it. Nothing holds it to + * the field: the endpoint's own first-party client reads `data` alone and never looks at it, + * where the sibling dotcom route reads its model back and asserts it — + * https://github.com/microsoft/vscode-copilot-chat/blob/5863f5a7088958050792b5dccbe8b46c6e13eccc/src/platform/embeddings/common/remoteEmbeddingsComputer.ts#L294-L301 + * + * Naming the model the request asked for is what the client can correlate against, and it is + * what another Copilot gateway meters under for the same reason — + * https://github.com/caozhiyuan/copilot-api/blob/96854ce1e1e741d621b12d4ca844efd77f6e8d90/src/routes/embeddings/route.ts#L16-L23 + */ +export const parseOpenAIEmbeddingsResponse = (value: unknown, requestedModel: string): CanonicalOpenAIEmbeddingsResponse => { + if (!isRecord(value)) throw new Error('OpenAI Embeddings response body must be an object'); + const { data } = value; + if (!Array.isArray(data)) throw new Error('data must be an array'); + const usage = parseUsage(value.usage); + const embeddings: CanonicalOpenAIEmbedding[] = data.map((entry, position) => { + if (!isRecord(entry)) throw new Error(`data[${position}] must be an object`); + return { + index: requiredInteger(entry.index, `data[${position}].index`), + values: parseEmbedding(entry.embedding, `data[${position}].embedding`), + }; + }); + return { + model: value.model === undefined ? requestedModel : requiredString(value.model, 'model'), + embeddings, + ...(usage === undefined ? {} : { usage }), + }; +}; + +/** + * Writes the answer in the encoding the client asked for, whichever one the upstream + * answered in. `object` is `x-stainless-const` at both levels, so it is a constant of the + * protocol rather than something an upstream varies, and it is written here rather than + * repeated back from the body. + */ +export const renderOpenAIEmbeddingsResponse = ( + format: OpenAIEmbeddingsEncodingFormat, + response: CanonicalOpenAIEmbeddingsResponse, +): Record => ({ + object: 'list', + data: response.embeddings.map(embedding => ({ + object: 'embedding', + index: embedding.index, + embedding: format === 'base64' ? renderEmbedding(embedding.values) : embedding.values, + })), + model: response.model, + ...(response.usage === undefined ? {} : { + usage: { prompt_tokens: response.usage.promptTokens, total_tokens: response.usage.totalTokens }, + }), +}); diff --git a/packages/protocols/src/openai-images/index.ts b/packages/protocols/src/openai-images/index.ts index 7b8462cdb8..e820333e23 100644 --- a/packages/protocols/src/openai-images/index.ts +++ b/packages/protocols/src/openai-images/index.ts @@ -1,3 +1,12 @@ +// The OpenAI Images protocol: POST /v1/images/generations and POST /v1/images/edits. One +// protocol over two endpoints — generations takes JSON, and edits takes either JSON, where +// every image is a URL, a data URL or a file id, or a multipart form carrying the files +// themselves. +// https://github.com/openai/openai-openapi/blob/a3276900e58b8b2a92e0cb087cd2e6e005f58458/openapi.yaml#L12858-L12870 +// https://github.com/openai/openai-openapi/blob/a3276900e58b8b2a92e0cb087cd2e6e005f58458/openapi.yaml#L12558-L12620 + +export type OpenAIImagesOperation = 'generations' | 'edits'; + // JSON payload accepted by POST /v1/images/generations. Field set follows // OpenAI's reference for gpt-image-* and legacy dall-e-* (dall-e is // retired but the union shape is harmless). Declared as an interface with @@ -29,3 +38,96 @@ export interface OpenAIImagesGenerationsPayload { export type OpenAIImageEditReference = | { image_url: string; file_id?: never; [key: string]: unknown } | { file_id: string; image_url?: never; [key: string]: unknown }; + +/** A file the client sent in a multipart form, held as bytes: a form is a parsed value like + * any other, and bytes are what survives being read once and what a dump can show. */ +export interface OpenAIImagesUploadedFile { + fileName: string; + mediaType: string; + bytes: Uint8Array; +} + +/** One image an edit reads. Multipart carries the file itself; JSON carries a reference for + * the upstream to resolve, kept exactly as the client wrote it — whether a data URL can be + * turned back into a file is the upstream serializer's question, not this one's. */ +export type OpenAIImagesEditImage = + | { kind: 'file'; file: OpenAIImagesUploadedFile } + | { kind: 'reference'; reference: OpenAIImageEditReference }; + +export interface CanonicalOpenAIImagesGenerationsRequest { + operation: 'generations'; + /** Everything the client sent but `model`: routing owns the model id, and what is left is + * what the upstream is asked for. */ + parameters: Record; +} + +export interface CanonicalOpenAIImagesEditsRequest { + operation: 'edits'; + images: OpenAIImagesEditImage[]; + mask?: OpenAIImagesEditImage; + parameters: Record; +} + +export type CanonicalOpenAIImagesRequest = CanonicalOpenAIImagesGenerationsRequest | CanonicalOpenAIImagesEditsRequest; + +export interface ParsedOpenAIImagesRequest { + model: string; + request: CanonicalOpenAIImagesRequest; +} + +/** One image the upstream returned. `response_format` decides which arm carries it on the + * dall-e models, while the GPT image models answer base64 and do not serve a URL at all, so + * neither arm is the one to expect. + * https://github.com/openai/openai-openapi/blob/a3276900e58b8b2a92e0cb087cd2e6e005f58458/openapi.yaml#L51044-L51067 */ +export interface CanonicalOpenAIImage { + url?: string; + base64?: string; + revisedPrompt?: string; +} + +/** Token counts as the images endpoints report them, disjoint: what the upstream attributed to + * images is taken out of the count beside it. Only the GPT image models report any of this — + * the dall-e models report nothing — which is why the whole reading is optional as well as + * each field. + * https://github.com/openai/openai-openapi/blob/a3276900e58b8b2a92e0cb087cd2e6e005f58458/openapi.yaml#L78089-L78115 */ +export interface CanonicalOpenAIImagesUsage { + inputTokens?: number; + inputImageTokens?: number; + outputTokens?: number; + outputImageTokens?: number; +} + +export interface CanonicalOpenAIImagesResponse { + /** The parsed body as it arrived. One protocol in and one out means rendering is + * re-serializing this, so the fields the gateway does not model still reach the client. */ + raw: Record; + images: CanonicalOpenAIImage[]; + usage?: CanonicalOpenAIImagesUsage; +} + +/** One event on a streamed answer. A stream carries partial images and then the completed one, + * and the completed one carries the `usage` block the non-streamed body carries — so what a + * call is billed by is stated the same way whichever shape it came back in. + * https://github.com/openai/openai-openapi/blob/a3276900e58b8b2a92e0cb087cd2e6e005f58458/openapi.yaml#L51337-L51424 + * https://github.com/openai/openai-openapi/blob/a3276900e58b8b2a92e0cb087cd2e6e005f58458/openapi.yaml#L51158-L51246 + * + * `type` is an open string rather than the four names the specification lists: an event type + * is the upstream's own word, and a shape added later has to reach the client whether or not + * this gateway has heard of it. The index signature carries everything not named here. */ +export interface OpenAIImagesStreamEvent { + type: string; + b64_json?: string; + created_at?: number; + size?: string; + quality?: string; + background?: string; + output_format?: string; + partial_image_index?: number; + usage?: unknown; + [key: string]: unknown; +} + +export { openaiImagesRequestWantsStream, parseOpenAIImagesEditsRequest, parseOpenAIImagesGenerationsRequest } from './request.ts'; +export { parseOpenAIImagesResponse, parseOpenAIImagesUsage, renderOpenAIImagesResponse } from './response.ts'; +export { OPENAI_IMAGES_MISSING_TERMINAL_MESSAGE, isOpenAIImagesTerminalEvent, parseOpenAIImagesStream, type ParseOpenAIImagesStreamOptions } from './stream.ts'; +export { openaiImagesStreamEventToSSEFrame } from './to-sse.ts'; diff --git a/packages/protocols/src/openai-images/request.ts b/packages/protocols/src/openai-images/request.ts new file mode 100644 index 0000000000..4a485787b7 --- /dev/null +++ b/packages/protocols/src/openai-images/request.ts @@ -0,0 +1,124 @@ +// Reading what the client sent into the canonical request. The two endpoints take different +// bodies — generations is JSON, edits is JSON or a multipart form — and each message here is +// what the client is told, so they name the endpoint and the field they are about. + +import type { CanonicalOpenAIImagesRequest, OpenAIImageEditReference, OpenAIImagesEditImage, OpenAIImagesUploadedFile, ParsedOpenAIImagesRequest } from './index.ts'; +import { isJsonMediaType, isMultipartFormDataMediaType } from '../common/media-type.ts'; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** + * Whether the client asked for the answer as a stream. + * + * The flag rides on to the upstream inside `parameters` exactly as it arrived, and this is the + * gateway's own reading of it. It is two values rather than one because an edit may be sent as + * a multipart form, where every field arrives as the text the client typed, while generations + * and a JSON edit carry the boolean the specification names. + * https://github.com/openai/openai-openapi/blob/a3276900e58b8b2a92e0cb087cd2e6e005f58458/openapi.yaml#L47542-L47673 + */ +export const openaiImagesRequestWantsStream = (request: CanonicalOpenAIImagesRequest): boolean => + request.parameters.stream === true || request.parameters.stream === 'true'; + +const jsonObject = (body: Uint8Array, endpoint: string): Record => { + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder().decode(body)) as unknown; + } catch { + throw new Error(`${endpoint} request body must be valid JSON.`); + } + if (!isRecord(parsed)) throw new Error(`${endpoint} request body must be an object.`); + return parsed; +}; + +/** Routing owns the model id, so it comes out of the payload rather than travelling inside it. + * Every other field is the upstream's business and is passed on untouched. */ +const requiredModel = (body: Record, endpoint: string): string => { + const { model } = body; + if (typeof model !== 'string' || model.length === 0) { + throw new Error(`${endpoint} request body must include a model string.`); + } + return model; +}; + +export const parseOpenAIImagesGenerationsRequest = (body: Uint8Array): ParsedOpenAIImagesRequest => { + const payload = jsonObject(body, 'OpenAI Images Generations'); + const model = requiredModel(payload, 'OpenAI Images Generations'); + const { model: _model, ...parameters } = payload; + return { model, request: { operation: 'generations', parameters } }; +}; + +export const parseOpenAIImagesEditsRequest = async ( + contentType: string | null | undefined, + body: Uint8Array, +): Promise => { + if (isJsonMediaType(contentType)) return jsonEdits(body); + if (isMultipartFormDataMediaType(contentType)) return await multipartEdits(contentType, body); + throw new Error('OpenAI Images Edits request body must use application/json or multipart/form-data.'); +}; + +const jsonEdits = (body: Uint8Array): ParsedOpenAIImagesRequest => { + const payload = jsonObject(body, 'OpenAI Images Edits'); + const model = requiredModel(payload, 'OpenAI Images Edits'); + if (!Array.isArray(payload.images)) { + throw new Error('OpenAI Images Edits request body must include an images array.'); + } + const images = payload.images.map((value, index) => referenceImage(value, `OpenAI Images Edits images[${index}]`)); + const mask = payload.mask === undefined ? undefined : referenceImage(payload.mask, 'OpenAI Images Edits mask'); + const { model: _model, images: _images, mask: _mask, ...parameters } = payload; + return { + model, + request: { operation: 'edits', images, ...(mask === undefined ? {} : { mask }), parameters }, + }; +}; + +const referenceImage = (value: unknown, path: string): OpenAIImagesEditImage => { + if (!isRecord(value)) throw new Error(`${path} must be an object.`); + const { image_url: imageUrl, file_id: fileId } = value; + const named = (typeof imageUrl === 'string' && fileId === undefined) + || (typeof fileId === 'string' && imageUrl === undefined); + if (!named) throw new Error(`${path} must contain exactly one string field: image_url or file_id.`); + return { kind: 'reference', reference: value as OpenAIImageEditReference }; +}; + +const multipartEdits = async (contentType: string, body: Uint8Array): Promise => { + let form: FormData; + try { + // `BodyInit` excludes a view over a `SharedArrayBuffer`, which an inbound body never is. + form = await new Response(body as BodyInit, { headers: { 'content-type': contentType } }).formData(); + } catch { + throw new Error('OpenAI Images Edits request body must be valid multipart/form-data.'); + } + + const model = form.get('model'); + if (typeof model !== 'string' || model.length === 0) { + throw new Error('OpenAI Images Edits request body must include a model field.'); + } + + const images: OpenAIImagesEditImage[] = []; + let mask: OpenAIImagesEditImage | undefined; + const parameters: Record = {}; + for (const [name, value] of form.entries()) { + if (name === 'model') continue; + // One image is sent as `image` and several as `image[]`; the upstream serializer picks the + // field name back off the count, so the two arrive at one list here. + if (name === 'image' || name === 'image[]') { + images.push({ kind: 'file', file: await uploadedFile(value, `OpenAI Images Edits ${name} fields must be files.`) }); + } else if (name === 'mask') { + mask = { kind: 'file', file: await uploadedFile(value, 'OpenAI Images Edits mask field must be a file.') }; + } else { + if (typeof value !== 'string') throw new Error(`OpenAI Images Edits ${name} field must be text.`); + parameters[name] = value; + } + } + + return { + model, + request: { operation: 'edits', images, ...(mask === undefined ? {} : { mask }), parameters }, + }; +}; + +const uploadedFile = async (value: FormDataEntryValue, message: string): Promise => { + if (!(value instanceof File)) throw new Error(message); + return { fileName: value.name, mediaType: value.type, bytes: new Uint8Array(await value.arrayBuffer()) }; +}; diff --git a/packages/protocols/src/openai-images/response.ts b/packages/protocols/src/openai-images/response.ts new file mode 100644 index 0000000000..38bb2f3b9b --- /dev/null +++ b/packages/protocols/src/openai-images/response.ts @@ -0,0 +1,91 @@ +// Reading the upstream's answer, and writing the client's. There is one images protocol, so +// the two are the same shape and rendering a success is re-serializing what was parsed — +// which is what keeps the fields this file does not name from being dropped on the way out. + +import type { CanonicalOpenAIImage, CanonicalOpenAIImagesResponse, CanonicalOpenAIImagesUsage } from './index.ts'; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +export const parseOpenAIImagesResponse = (value: unknown): CanonicalOpenAIImagesResponse => { + if (!isRecord(value)) throw new Error('OpenAI Images response body must be an object'); + const usage = parseOpenAIImagesUsage(value); + return { + raw: value, + images: parseOpenAIImages(value.data), + ...(usage === undefined ? {} : { usage }), + }; +}; + +// An answer that carries no `data` at all is not malformed — an upstream reporting a +// moderation refusal in a 200 does exactly that — so it parses to no images, and only a `data` +// that is there but cannot be read is an error. +const parseOpenAIImages = (data: unknown): CanonicalOpenAIImage[] => { + if (data === undefined) return []; + if (!Array.isArray(data)) throw new Error('OpenAI Images response data must be an array'); + return data.map((entry, index) => { + if (!isRecord(entry)) throw new Error(`OpenAI Images response data[${index}] must be an object`); + const url = optionalString(entry.url, `OpenAI Images response data[${index}].url`); + const base64 = optionalString(entry.b64_json, `OpenAI Images response data[${index}].b64_json`); + const revisedPrompt = optionalString(entry.revised_prompt, `OpenAI Images response data[${index}].revised_prompt`); + return { + ...(url === undefined ? {} : { url }), + ...(base64 === undefined ? {} : { base64 }), + ...(revisedPrompt === undefined ? {} : { revisedPrompt }), + }; + }); +}; + +const optionalString = (value: unknown, field: string): string | undefined => { + if (value === undefined) return undefined; + if (typeof value !== 'string') throw new Error(`${field} must be a string`); + return value; +}; + +/** + * What the upstream said it charged for, or nothing when it said nothing readable. The + * distinction is the whole point of the return type: an upstream that reported no usage is a + * different fact from one that reported zero, and only one of them is a reading. + */ +export const parseOpenAIImagesUsage = (value: unknown): CanonicalOpenAIImagesUsage | undefined => { + if (!isRecord(value) || !isRecord(value.usage)) return undefined; + const { + input_tokens: inputTotal, + output_tokens: outputTotal, + input_tokens_details: inputDetails, + output_tokens_details: outputDetails, + } = value.usage; + if (inputTotal !== undefined && typeof inputTotal !== 'number') return undefined; + if (outputTotal !== undefined && typeof outputTotal !== 'number') return undefined; + if (inputTotal === undefined && outputTotal === undefined) return undefined; + + const input = splitModality(inputTotal, inputDetails); + const output = splitModality(outputTotal, outputDetails); + if (input === null || output === null) return undefined; + return { + ...(input.text === undefined ? {} : { inputTokens: input.text }), + ...(input.image === undefined ? {} : { inputImageTokens: input.image }), + ...(output.text === undefined ? {} : { outputTokens: output.text }), + ...(output.image === undefined ? {} : { outputImageTokens: output.image }), + }; +}; + +// `input_tokens` counts images and text together and `input_tokens_details` says how much of it +// was images, so the two are made disjoint here: what stays under the plain count is the text. +// A details object carrying neither split says nothing and is as good as absent, while one that +// cannot be read discards the whole reading rather than half of it. +const splitModality = ( + total: number | undefined, + details: unknown, +): { text?: number; image?: number } | null => { + if (total === undefined) return {}; + if (details === undefined) return { text: total }; + if (!isRecord(details)) return null; + const { text_tokens: text, image_tokens: image } = details; + if (text !== undefined && typeof text !== 'number') return null; + if (image !== undefined && typeof image !== 'number') return null; + if (text === undefined && image === undefined) return { text: total }; + return { text: text ?? 0, image: image ?? 0 }; +}; + +export const renderOpenAIImagesResponse = (response: CanonicalOpenAIImagesResponse): Record => response.raw; diff --git a/packages/protocols/src/openai-images/stream.ts b/packages/protocols/src/openai-images/stream.ts new file mode 100644 index 0000000000..0596177163 --- /dev/null +++ b/packages/protocols/src/openai-images/stream.ts @@ -0,0 +1,51 @@ +// Reading a streamed answer. Both endpoints accept `stream: true` and answer with SSE that +// carries the same two shapes under their own names — `image_generation.partial_image` and +// `image_generation.completed` on generations, `image_edit.*` on edits — and the sequence ends +// at the completed event rather than at a sentinel: this protocol has no `[DONE]`. +// https://github.com/openai/openai-openapi/blob/a3276900e58b8b2a92e0cb087cd2e6e005f58458/openapi.yaml#L13077-L13086 +// https://github.com/openai/openai-openapi/blob/a3276900e58b8b2a92e0cb087cd2e6e005f58458/openapi.yaml#L12846-L12857 + +import type { OpenAIImagesStreamEvent } from './index.ts'; +import { parseTargetStreamFrames } from '../common/parse-events.ts'; +import { parseSSEStream } from '../common/parse-sse.ts'; + +export interface ParseOpenAIImagesStreamOptions { + signal?: AbortSignal; +} + +export const OPENAI_IMAGES_MISSING_TERMINAL_MESSAGE = 'OpenAI Images stream ended without a completed event.'; + +/** The stream is over when the image is. Each endpoint names its own terminal — + * `image_generation.completed` and `image_edit.completed` — so the suffix is the whole of + * what the two have in common, which is also what makes this reading serve an endpoint added + * later without being taught its prefix. */ +export const isOpenAIImagesTerminalEvent = (event: OpenAIImagesStreamEvent): boolean => event.type.endsWith('.completed'); + +/** + * The upstream's SSE body as this protocol's events. Transport framing ends here: what comes + * out carries OpenAI Images events, and nothing that reads them knows how they arrived. + */ +export const parseOpenAIImagesStream = ( + body: ReadableStream, + options: ParseOpenAIImagesStreamOptions = {}, +): AsyncGenerator => (async function* () { + for await (const frame of parseTargetStreamFrames>(parseSSEStream(body, options), { protocol: 'OpenAI Images' })) { + // `[DONE]` is not part of this protocol. An OpenAI-compatible upstream that appends the + // sentinel it writes elsewhere is saying the body is over, which is all it can mean here — + // whether the image finished is what the completed event says, and that has already passed. + if (frame.type === 'done') return; + yield named(frame.data, frame.frame.event); + } +})(); + +/** The event's own name, which the specification writes twice: as the SSE `event:` label and + * as `type` inside the payload. Upstreams that write only the label have been seen on the + * Responses protocol, and every reader here takes the name off the payload, so the two are + * reconciled once at the boundary rather than at each reader. An event that carries neither + * is not one this protocol can place — it could be the terminal or a partial — so it ends the + * read rather than being passed on as an unknown. */ +const named = (event: Record, label: string | undefined): OpenAIImagesStreamEvent => { + if (typeof event.type === 'string') return event as OpenAIImagesStreamEvent; + if (label === undefined) throw new Error('OpenAI Images stream event carries no type, on the payload or as an SSE event name'); + return { ...event, type: label } as OpenAIImagesStreamEvent; +}; diff --git a/packages/protocols/src/openai-images/to-sse.ts b/packages/protocols/src/openai-images/to-sse.ts new file mode 100644 index 0000000000..8f4d2410e2 --- /dev/null +++ b/packages/protocols/src/openai-images/to-sse.ts @@ -0,0 +1,9 @@ +import type { OpenAIImagesStreamEvent } from './index.ts'; +import { type SseFrame, sseFrame } from '../common/index.ts'; + +// The specification writes an event's name twice — as the SSE `event:` label and as `type` +// inside the payload — and the stream parser reconciles the two on the way in, so writing the +// label back off the payload writes the name that arrived. +// https://github.com/openai/openai-openapi/blob/a3276900e58b8b2a92e0cb087cd2e6e005f58458/openapi.yaml#L13077-L13086 +export const openaiImagesStreamEventToSSEFrame = (event: OpenAIImagesStreamEvent): SseFrame => + sseFrame(JSON.stringify(event), event.type); diff --git a/packages/provider-azure/__tests__/provider_test.ts b/packages/provider-azure/__tests__/provider_test.ts index d5ea92ea3d..5f3ad90e20 100644 --- a/packages/provider-azure/__tests__/provider_test.ts +++ b/packages/provider-azure/__tests__/provider_test.ts @@ -3,7 +3,7 @@ import { test } from 'vitest'; import { createAzureProvider } from '../src/provider.ts'; import type { UpstreamRecord } from '@floway-dev/provider'; import { directFetcher } from '@floway-dev/provider'; -import { assertEquals, noopUpstreamCallOptions, sseResponse, withMockedFetch } from '@floway-dev/test-utils'; +import { callProviderPipeline, assertEquals, noopUpstreamCallOptions, sseResponse, withMockedFetch } from '@floway-dev/test-utils'; const azureRecord = (overrides: Partial = {}): UpstreamRecord => { const config = { @@ -134,7 +134,7 @@ test('createAzureProvider sends upstream model ids in OpenAI-shaped request bodi }], }], }, 'generate', undefined, noopUpstreamCallOptions()); - const embeddings = await instance.instance.callOpenAIEmbeddings(providerModel, { input: 'hello' }, undefined, noopUpstreamCallOptions()); + const embeddings = await callProviderPipeline(instance, 'openaiEmbeddings', providerModel, { input: 'hello' }, undefined, noopUpstreamCallOptions()); assertEquals(chat.modelKey, 'gpt-prod'); assertEquals(responses.modelKey, 'gpt-prod'); @@ -476,7 +476,7 @@ test('createAzureProvider exposes image models and routes generations with api-v const models = await provider.instance.getProvidedModels(directFetcher); assertEquals(models[0].kind, 'image'); assertEquals(models[0].endpoints, { openaiImagesGenerations: {}, openaiImagesEdits: {} }); - const result = await provider.instance.callOpenAIImagesGenerations(models[0], { prompt: 'hello' }, undefined, noopUpstreamCallOptions()); + const result = await callProviderPipeline(provider, 'openaiImagesGenerations', models[0], { prompt: 'hello' }, undefined, noopUpstreamCallOptions()); assertEquals(result.modelKey, 'gpt-image-2'); assertEquals(result.response.status, 200); }, @@ -524,11 +524,11 @@ test('createAzureProvider callOpenAIImagesEdits posts multipart with model repla async () => { const provider = createAzureProvider(record); const models = await provider.instance.getProvidedModels(directFetcher); - const result = await provider.instance.callOpenAIImagesEdits(models[0], { + const result = await callProviderPipeline(provider, 'openaiImagesEdits', models[0], { parameters: { prompt: 'replace sky' }, images: [{ type: 'upload', - file: new File([new Uint8Array([1, 2, 3])], 'photo.png', { type: 'image/png' }), + file: { bytes: new Uint8Array([1, 2, 3]), name: 'photo.png', type: 'image/png' }, }], }, undefined, noopUpstreamCallOptions()); assertEquals(result.modelKey, 'gpt-image-2'); @@ -559,9 +559,9 @@ test('createAzureProvider callOpenAIAudioTranscriptions selects the deployment i async () => { const provider = createAzureProvider(record); const [model] = await provider.instance.getProvidedModels(directFetcher); - const result = await provider.instance.callOpenAIAudioTranscriptions(model, { + const result = await callProviderPipeline(provider, 'openaiAudioTranscriptions', model, { entries: [ - { name: 'file', value: new File(['audio'], 'clip.mp3', { type: 'audio/mpeg' }) }, + { name: 'file', value: { bytes: new TextEncoder().encode('audio'), name: 'clip.mp3', type: 'audio/mpeg' } }, { name: 'model', value: 'public-model' }, { name: 'response_format', value: 'vtt' }, ], @@ -593,10 +593,10 @@ test('createAzureProvider callOpenAIAudioTranscriptions reduces a Foundry endpoi async () => { const provider = createAzureProvider(record); const [model] = await provider.instance.getProvidedModels(directFetcher); - await provider.instance.callOpenAIAudioTranscriptions(model, { + await callProviderPipeline(provider, 'openaiAudioTranscriptions', model, { entries: [ { name: 'model', value: 'public-model' }, - { name: 'file', value: new File(['audio'], 'clip.wav', { type: 'audio/wav' }) }, + { name: 'file', value: { bytes: new TextEncoder().encode('audio'), name: 'clip.wav', type: 'audio/wav' } }, ], }, undefined, noopUpstreamCallOptions()); }, diff --git a/packages/provider-azure/package.json b/packages/provider-azure/package.json index 2944619f6a..432f72a585 100644 --- a/packages/provider-azure/package.json +++ b/packages/provider-azure/package.json @@ -10,6 +10,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@floway-dev/http": "workspace:*", + "@floway-dev/pipeline": "workspace:*", "@floway-dev/interceptor": "workspace:*", "@floway-dev/protocols": "workspace:*", "@floway-dev/provider": "workspace:*" diff --git a/packages/provider-azure/src/fetch.ts b/packages/provider-azure/src/fetch.ts index c59b88e0bd..9d4572b7ff 100644 --- a/packages/provider-azure/src/fetch.ts +++ b/packages/provider-azure/src/fetch.ts @@ -1,5 +1,5 @@ import type { AzureUpstreamConfig } from './config.ts'; -import { azureAnthropicBaseUrl, azureOpenAiV1BaseUrl, azureResourceRootUrl } from './endpoint.ts'; +import { azureAnthropicBaseUrl, azureOpenAiV1BaseUrl } from './endpoint.ts'; import { type FetchInit, type UpstreamFetchOptions, joinBaseAndPath } from '@floway-dev/provider'; const azureFetchUrl = async ( @@ -79,10 +79,6 @@ export const azureFetchOpenAIImagesEdits = (config: AzureUpstreamConfig, init: F // https://github.com/Azure/azure-rest-api-specs/blob/928047803788f7377fa003a26ba2bdc2e0fcccc0/specification/cognitiveservices/OpenAI.Inference/routes/audio_transcription.tsp#L19-L49 export const azureFetchOpenAIAudioTranscriptions = (config: AzureUpstreamConfig, deployment: string, init: FetchInit, options: UpstreamFetchOptions): Promise => azureFetchUrl(config, 'openai', azureDeploymentScopedOpenAIAudioTranscriptionUrl(config, deployment), init, options); -// The decision contract is resource-scoped, so the path is absolute from the -// resource host rather than relative to the OpenAI v1 base. -export const azureFetchDecision = (config: AzureUpstreamConfig, path: string, init: FetchInit, options: UpstreamFetchOptions): Promise => - azureFetchUrl(config, 'openai', joinBaseAndPath(azureResourceRootUrl(config.endpoint), path), init, options); export const azureFetchAnthropicMessages = (config: AzureUpstreamConfig, init: FetchInit, options: UpstreamFetchOptions): Promise => azureFetchInternal(config, 'anthropic', '/v1/messages', init, options); export const azureFetchAnthropicMessagesCountTokens = (config: AzureUpstreamConfig, init: FetchInit, options: UpstreamFetchOptions): Promise => diff --git a/packages/provider-azure/src/index.ts b/packages/provider-azure/src/index.ts index 744129dbdc..1b26b37a02 100644 --- a/packages/provider-azure/src/index.ts +++ b/packages/provider-azure/src/index.ts @@ -1,6 +1,7 @@ import { AZURE_DEFAULT_FLAGS } from './defaults.ts'; import { createAzureProvider } from './provider.ts'; import type { ProviderModule } from '@floway-dev/provider'; +export { createAzurePipelines } from './pipelines.ts'; export const azureProviderModule: ProviderModule = { create: createAzureProvider, diff --git a/packages/provider-azure/src/pipelines.ts b/packages/provider-azure/src/pipelines.ts new file mode 100644 index 0000000000..8dd3c64e48 --- /dev/null +++ b/packages/provider-azure/src/pipelines.ts @@ -0,0 +1,14 @@ +import type { AzureUpstreamConfig } from './config.ts'; +import { prepareAzureRequest } from './stages/prepare-request.ts'; +import { http } from '@floway-dev/http/pipeline'; +import { compose } from '@floway-dev/pipeline'; +import type { ProviderOperationPayloads, ProviderOperationResponse, ProviderPipelines, ProviderRequest } from '@floway-dev/provider'; + +export const createAzurePipelines = (config: AzureUpstreamConfig): ProviderPipelines => ({ + openaiCompletions: compose, ProviderOperationResponse<'openaiCompletions'>>('azure.openaiCompletions', [prepareAzureRequest(config, 'openaiCompletions'), http]), + openaiEmbeddings: compose, ProviderOperationResponse<'openaiEmbeddings'>>('azure.openaiEmbeddings', [prepareAzureRequest(config, 'openaiEmbeddings'), http]), + openaiImagesGenerations: compose, ProviderOperationResponse<'openaiImagesGenerations'>>('azure.openaiImagesGenerations', [prepareAzureRequest(config, 'openaiImagesGenerations'), http]), + openaiImagesEdits: compose, ProviderOperationResponse<'openaiImagesEdits'>>('azure.openaiImagesEdits', [prepareAzureRequest(config, 'openaiImagesEdits'), http]), + openaiAudioTranscriptions: compose, ProviderOperationResponse<'openaiAudioTranscriptions'>>('azure.openaiAudioTranscriptions', [prepareAzureRequest(config, 'openaiAudioTranscriptions'), http]), + decision: compose, ProviderOperationResponse<'decision'>>('azure.decision', [prepareAzureRequest(config, 'decision'), http]), +}); diff --git a/packages/provider-azure/src/provider.ts b/packages/provider-azure/src/provider.ts index b5d82564f6..75d9738fe2 100644 --- a/packages/provider-azure/src/provider.ts +++ b/packages/provider-azure/src/provider.ts @@ -1,12 +1,12 @@ import { assertAzureUpstreamRecord } from './config.ts'; import { AZURE_DEFAULT_FLAGS } from './defaults.ts'; -import { azureFetchDecision, azureFetchOpenAIAudioTranscriptions, azureFetchOpenAIChatCompletions, azureFetchOpenAICompletions, azureFetchOpenAIEmbeddings, azureFetchOpenAIImagesEdits, azureFetchOpenAIImagesGenerations, azureFetchAnthropicMessages, azureFetchAnthropicMessagesCountTokens, azureFetchOpenAIResponses, azureFetchOpenAIResponsesCompact } from './fetch.ts'; +import { azureFetchOpenAIAudioTranscriptions, azureFetchOpenAIChatCompletions, azureFetchOpenAICompletions, azureFetchOpenAIEmbeddings, azureFetchOpenAIImagesEdits, azureFetchOpenAIImagesGenerations, azureFetchAnthropicMessages, azureFetchAnthropicMessagesCountTokens, azureFetchOpenAIResponses, azureFetchOpenAIResponsesCompact } from './fetch.ts'; import { AZURE_OPENAI_RESPONSES_BOUNDARY } from './interceptors/openai-responses/index.ts'; import type { OpenAIResponsesBoundaryCtx } from './interceptors/openai-responses/types.ts'; +import { createAzurePipelines } from './pipelines.ts'; import { runInterceptors } from '@floway-dev/interceptor'; import { parseAnthropicMessagesStream } from '@floway-dev/protocols/anthropic-messages'; import { kindForEndpoints } from '@floway-dev/protocols/common'; -import { DEFAULT_DECISION_PATHS, serializeDecisionRequest, type CanonicalDecisionRequest } from '@floway-dev/protocols/decision'; import { parseOpenAIChatCompletionsStream } from '@floway-dev/protocols/openai-chat-completions'; import { parseOpenAIResponsesStream, type OpenAIResponsesCompactionResultEx, toCompactPayloadShape } from '@floway-dev/protocols/openai-responses'; import { headersForAnthropicMessagesCall, jsonRequestBody, serializeModelPathOpenAIAudioTranscriptionRequest, serializeOpenAIImagesEditsRequest, type FetchInit, type HttpHeaderLines, type ProviderInstance, type Provider, type ProviderModel, type ProviderOpenAIResponsesResult, type ProviderStreamParser, type UpstreamCallOptions, type UpstreamFetchOptions, type UpstreamRecord, publicModelId, resolveEffectiveFlags, streamingProviderCall } from '@floway-dev/provider'; @@ -126,19 +126,6 @@ export const createAzureProvider = (record: UpstreamRecord): Provider => { return { response, modelKey: upstreamModelId }; }, callRerank: () => Promise.reject(new Error('Azure provider does not support callRerank')), - callDecision: async (model: ProviderModel, request: CanonicalDecisionRequest, signal: AbortSignal | undefined, opts: UpstreamCallOptions) => { - const target = model.decisionTarget; - if (target === undefined) throw new Error(`Decision model ${model.id} has no outbound target`); - const upstreamModelId = upstreamModelIdOf(model); - const body = serializeDecisionRequest(target.protocol, upstreamModelId, request); - const response = await azureFetchDecision( - azure.config, - target.path ?? DEFAULT_DECISION_PATHS[target.protocol], - { method: 'POST', body: jsonRequestBody(body), signal }, - { extraHeaders: [...opts.headers], fetcher: opts.fetcher, wrapUpstreamCall: opts.wrapUpstreamCall }, - ); - return { response, modelKey: upstreamModelId, target }; - }, }; return { @@ -149,6 +136,7 @@ export const createAzureProvider = (record: UpstreamRecord): Provider => { disabledPublicModelIds: azure.disabledPublicModelIds, modelPrefix: azure.modelPrefix, modelsCache: azure.modelsCache, + pipelines: createAzurePipelines(azure.config), instance, }; }; diff --git a/packages/provider-azure/src/stages/prepare-request.ts b/packages/provider-azure/src/stages/prepare-request.ts new file mode 100644 index 0000000000..c4ce681dfa --- /dev/null +++ b/packages/provider-azure/src/stages/prepare-request.ts @@ -0,0 +1,81 @@ +import type { AzureUpstreamConfig } from '../config.ts'; +import { azureOpenAiV1BaseUrl, azureResourceRootUrl } from '../endpoint.ts'; +import { type HttpRequestFacts, type HttpResponseFacts } from '@floway-dev/http/pipeline'; +import { multipartBody, type HttpBody, type HttpBodyEncoding } from '@floway-dev/http/request-content'; +import { defineStage, move, secret } from '@floway-dev/pipeline'; +import { DEFAULT_DECISION_PATHS, serializeDecisionRequest } from '@floway-dev/protocols/decision'; +import { joinBaseAndPath, mergeHttpHeaders, prepareOpenAIImagesEditsBody, withHttpContentType, type ProviderOperationPayloads, type ProviderRequest, type ProviderResponse, type ProviderServices } from '@floway-dev/provider'; + +type AzureOperation = 'openaiCompletions' | 'openaiEmbeddings' | 'openaiImagesGenerations' | 'openaiImagesEdits' | 'openaiAudioTranscriptions' | 'decision'; + +interface AzureHttpFacts extends HttpRequestFacts { + 'request.azure.deployment': string; +} + +const PATHS: Record, string> = { + openaiCompletions: '/completions', + openaiEmbeddings: '/embeddings', + openaiImagesGenerations: '/images/generations', + openaiImagesEdits: '/images/edits', +}; + +export const prepareAzureRequest = (config: AzureUpstreamConfig, operation: O) => { + const prepare = defineStage, AzureHttpFacts, HttpResponseFacts, ProviderResponse, ProviderServices>({ + name: `prepareAzure${operation.replace(/^openai/, 'OpenAI').replace(/^alpha/, 'Alpha').replace(/^rerank/, 'Rerank').replace(/^decision/, 'Decision')}`, + through: { + request: { + needs: ['request.provider.model', 'request.provider.payload', 'request.http.callId', 'request.http.headers'], + consumes: ['request.provider.model', 'request.provider.payload', 'request.http.headers'], + provides: ['request.http.url', 'request.http.method', 'request.http.headers', 'request.http.body', 'request.http.encoding', 'request.azure.deployment'], + }, + response: { needs: ['response.http.exchange'], consumes: [], provides: ['response.provider.modelKey', 'response.provider.called', 'response.provider.previousCalls'] }, + }, + execute: async (facts, next) => { + const { 'request.provider.model': model, 'request.provider.payload': payload, ...rest } = facts; + const deployment = (model.providerData as { upstreamModelId: string }).upstreamModelId; + let body: HttpBody; + let encoding: HttpBodyEncoding = 'json'; + let url: URL; + if (operation === 'openaiAudioTranscriptions') { + body = multipartBody((payload as ProviderOperationPayloads['openaiAudioTranscriptions']).entries.filter(entry => entry.name !== 'model')); + encoding = 'multipart'; + url = new URL(config.endpoint); + url.pathname = `/openai/deployments/${encodeURIComponent(deployment)}/audio/transcriptions`; + url.search = ''; + url.hash = ''; + // https://github.com/Azure/azure-rest-api-specs/blob/928047803788f7377fa003a26ba2bdc2e0fcccc0/specification/cognitiveservices/OpenAI.Inference/routes/audio_transcription.tsp#L19-L49 + url.searchParams.set('api-version', '2025-04-01-preview'); + } else if (operation === 'decision') { + const target = model.decisionTarget; + if (target === undefined) throw new Error(`Decision model ${model.id} has no outbound target`); + // The decision contract is resource-scoped, so the path is absolute from the resource + // host rather than relative to the OpenAI v1 base. + url = new URL(joinBaseAndPath(azureResourceRootUrl(config.endpoint), target.path ?? DEFAULT_DECISION_PATHS[target.protocol])); + body = serializeDecisionRequest(target.protocol, deployment, payload as ProviderOperationPayloads['decision']); + } else { + url = new URL(joinBaseAndPath(azureOpenAiV1BaseUrl(config.endpoint), PATHS[operation as Exclude])); + if (operation === 'openaiImagesEdits') { + const prepared = await prepareOpenAIImagesEditsBody(payload as ProviderOperationPayloads['openaiImagesEdits'], deployment); + body = prepared.body; + encoding = prepared.encoding; + } else { + body = { ...payload, model: deployment }; + } + if (operation === 'openaiImagesGenerations' || operation === 'openaiImagesEdits') url.searchParams.append('api-version', 'preview'); + } + const base = withHttpContentType([['api-key', secret(config.apiKey)]], body, encoding); + const headers = mergeHttpHeaders(base, facts['request.http.headers']); + const back = await next(move({ + ...rest, + 'request.azure.deployment': deployment, + 'request.http.url': url.href, + 'request.http.method': 'POST', + 'request.http.headers': headers, + 'request.http.body': body, + 'request.http.encoding': encoding, + })); + return move({ ...back, 'response.provider.modelKey': deployment, 'response.provider.called': back['response.http.exchange'].type === 'response', 'response.provider.previousCalls': [] }); + }, + }); + return prepare; +}; diff --git a/packages/provider-claude-code/src/provider.ts b/packages/provider-claude-code/src/provider.ts index 6f11845f70..4dca265200 100644 --- a/packages/provider-claude-code/src/provider.ts +++ b/packages/provider-claude-code/src/provider.ts @@ -118,7 +118,6 @@ export const createClaudeCodeProvider = (record: UpstreamRecord): Provider => { callOpenAIImagesEdits: rejectUnsupported('callOpenAIImagesEdits'), callOpenAIAudioTranscriptions: rejectUnsupported('callOpenAIAudioTranscriptions'), callRerank: rejectUnsupported('callRerank'), - callDecision: rejectUnsupported('callDecision'), }; return { @@ -129,6 +128,7 @@ export const createClaudeCodeProvider = (record: UpstreamRecord): Provider => { disabledPublicModelIds: record.disabledPublicModelIds, modelPrefix: record.modelPrefix, modelsCache: record.modelsCache, + pipelines: {}, instance, }; }; diff --git a/packages/provider-codex/__tests__/provider_test.ts b/packages/provider-codex/__tests__/provider_test.ts index fe5c8b6eec..91ea000937 100644 --- a/packages/provider-codex/__tests__/provider_test.ts +++ b/packages/provider-codex/__tests__/provider_test.ts @@ -4,8 +4,10 @@ import { createUpstreamStateRepoStub, type UpstreamStateRepoStub } from './upstr import { CODEX_RESPONSES_LITE_CLIENT_METADATA_KEY, CODEX_RESPONSES_LITE_HEADER } from '../src/constants.ts'; import { createCodexProvider } from '../src/provider.ts'; import type { CodexAccessTokenEntry, CodexUpstreamState } from '../src/state.ts'; -import { directFetcher, initProviderRepo, type UpstreamRecord } from '@floway-dev/provider'; -import { noopUpstreamCallOptions, readJsonRequest, stubProviderModel } from '@floway-dev/test-utils'; +import { exchangeResponse } from '@floway-dev/http/pipeline'; +import { move, run, setRelease, type Event } from '@floway-dev/pipeline'; +import { directFetcher, initProviderRepo, providerModelFacts, type UpstreamRecord } from '@floway-dev/provider'; +import { callProviderPipeline, noopUpstreamCallOptions, readJsonRequest, stubProviderModel } from '@floway-dev/test-utils'; const farFutureMs = Date.now() + 24 * 60 * 60 * 1000; @@ -92,6 +94,68 @@ const oauthTokenResponse = (overrides: Partial<{ access_token: string; refresh_t }), { status: 200, headers: new Headers({ 'content-type': 'application/json' }) }); describe('createCodexProvider', () => { + test('Codex Images records the complete parsed 401 body and preserves its preceding endpoint observation', async () => { + const refusal = { error: { code: 'invalid_token', message: 'expired bearer' }, diagnostic: 'x'.repeat(600) }; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + if (String(input) === 'https://auth.openai.com/oauth/token') return oauthTokenResponse(); + return new Headers(init?.headers).get('authorization') === 'Bearer at' + ? Response.json(refusal, { status: 401 }) + : Response.json({ created: 1, data: [] }); + }); + const provider = createCodexProvider(current!); + const model = stubProviderModel({ id: 'gpt-image-2', kind: 'image', endpoints: { openaiImagesGenerations: {}, openaiImagesEdits: {} } }); + const events: Event[] = []; + const options = noopUpstreamCallOptions(); + const executed = await run(provider.pipelines.openaiImagesGenerations!, move({ + 'request.provider.model': providerModelFacts(model), + 'request.provider.payload': { prompt: 'circle' }, + 'request.http.callId': 0, + 'request.http.headers': [], + }), { httpCall: () => ({ ...options, signal: undefined }), dump: (event: Event) => { events.push(event); } }); + expect(events.some(event => event.type === 'stage.leaved' && JSON.stringify(event.facts['response.codex.failureBody']) === JSON.stringify(refusal))).toBe(true); + expect(executed.facts['response.provider.previousCalls']).toEqual([{ modelKey: 'gpt-image-2' }]); + const exchange = executed.facts['response.http.exchange']; + if (exchange.type !== 'response') throw new Error('expected final model reply'); + await exchangeResponse(exchange).arrayBuffer(); + if (exchange.body !== null) setRelease(exchange.body, async () => {}); + await executed.drain(); + }); + + test('Codex Images pipeline preserves the image turn and payload across a renewable 401 retry', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + if (String(input) === 'https://auth.openai.com/oauth/token') return oauthTokenResponse(); + const headers = new Headers(init?.headers); + return headers.get('authorization') === 'Bearer at' + ? new Response(JSON.stringify({ error: { code: 'invalid_token', message: 'expired bearer' } }), { status: 401 }) + : new Response(JSON.stringify({ created: 1, data: [{ b64_json: 'image' }] }), { status: 201, headers: { 'x-upstream': 'retained' } }); + }); + const provider = createCodexProvider(current!); + const model = stubProviderModel({ id: 'gpt-image-2', kind: 'image', endpoints: { openaiImagesGenerations: {}, openaiImagesEdits: {} } }); + const options = noopUpstreamCallOptions(); + options.headers.set('x-codex-image-turn-id', 'stable-image-turn'); + const result = await callProviderPipeline(provider, 'openaiImagesGenerations', model, { prompt: 'blue circle' }, undefined, options); + expect(result.called).toBe(true); + expect(result.previousCalls).toEqual([{ modelKey: 'gpt-image-2' }]); + expect(result.response.status).toBe(201); + expect(result.response.headers.get('x-upstream')).toBe('retained'); + const imageCalls = fetchSpy.mock.calls.filter(([input]) => String(input).endsWith('/codex/images/generations')); + expect(imageCalls).toHaveLength(2); + expect(imageCalls.map(([, init]) => new Headers(init?.headers).get('x-codex-image-turn-id'))).toEqual(['stable-image-turn', 'stable-image-turn']); + expect(await Promise.all(imageCalls.map(([, init]) => readJsonRequest(init!)))).toEqual([{ prompt: 'blue circle', model: 'gpt-image-2' }, { prompt: 'blue circle', model: 'gpt-image-2' }]); + }); + + test('Codex Images pipeline marks a preflight Free-plan refusal as an uncalled model endpoint', async () => { + current = { ...current!, state: { accounts: [{ ...(current!.state as CodexUpstreamState).accounts[0], accessToken: { ...freshAccessToken, planType: 'free' } }] } }; + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + const provider = createCodexProvider(current); + const model = stubProviderModel({ id: 'gpt-image-2', kind: 'image', endpoints: { openaiImagesGenerations: {}, openaiImagesEdits: {} } }); + const result = await callProviderPipeline(provider, 'openaiImagesGenerations', model, { prompt: 'blue circle' }); + expect(result.response.status).toBe(403); + expect(result.called).toBe(false); + expect(result.previousCalls).toEqual([]); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + test('owns request identity and turn metadata headers on the instance', () => { const provider = createCodexProvider(baseRecord); @@ -354,7 +418,7 @@ describe('createCodexProvider', () => { const model = stubProviderModel({ id: 'gpt-image-2', display_name: 'GPT-Image-2', kind: 'image', endpoints: { openaiImagesGenerations: {}, openaiImagesEdits: {} } }); const options = noopUpstreamCallOptions(); options.headers.set('x-codex-image-turn-id', 'turn-image'); - const result = await instance.instance.callOpenAIImagesGenerations(model, { prompt: 'an orange circle', quality: 'low' }, undefined, options); + const result = await callProviderPipeline(instance, 'openaiImagesGenerations', model, { prompt: 'an orange circle', quality: 'low' }, undefined, options); expect(result.response.status).toBe(200); expect(result.modelKey).toBe('gpt-image-2'); const [url, init] = fetchSpy.mock.calls[0]; @@ -374,7 +438,7 @@ describe('createCodexProvider', () => { }; const instance = createCodexProvider(freeRecord); const model = stubProviderModel({ id: 'gpt-image-2', display_name: 'GPT-Image-2', kind: 'image', endpoints: { openaiImagesGenerations: {}, openaiImagesEdits: {} } }); - const result = await instance.instance.callOpenAIImagesGenerations(model, { prompt: 'an orange circle' }, undefined, noopUpstreamCallOptions()); + const result = await callProviderPipeline(instance, 'openaiImagesGenerations', model, { prompt: 'an orange circle' }, undefined, noopUpstreamCallOptions()); expect(result.response.status).toBe(403); expect(fetchSpy).not.toHaveBeenCalled(); }); @@ -386,7 +450,7 @@ describe('createCodexProvider', () => { }; const instance = createCodexProvider(freeRecord); const model = stubProviderModel({ id: 'gpt-image-2', display_name: 'GPT-Image-2', kind: 'image', endpoints: { openaiImagesGenerations: {}, openaiImagesEdits: {} } }); - const result = await instance.instance.callOpenAIImagesEdits(model, { + const result = await callProviderPipeline(instance, 'openaiImagesEdits', model, { images: [{ type: 'reference', reference: { image_url: 'https://example.test/image.png' } }], parameters: { prompt: 'edit' }, }, undefined, noopUpstreamCallOptions()); @@ -405,8 +469,8 @@ describe('createCodexProvider', () => { const model = stubProviderModel({ id: 'gpt-image-2', display_name: 'GPT-Image-2', kind: 'image', endpoints: { openaiImagesGenerations: {}, openaiImagesEdits: {} } }); const options = noopUpstreamCallOptions(); options.headers.set('originator', 'chatgpt_cca'); - const result = await instance.instance.callOpenAIImagesEdits(model, { - images: [{ type: 'upload', file: new File(['image'], 'image.png', { type: 'image/png' }) }], + const result = await callProviderPipeline(instance, 'openaiImagesEdits', model, { + images: [{ type: 'upload', file: { bytes: new TextEncoder().encode('image'), name: 'image.png', type: 'image/png' } }], parameters: { prompt: 'make it blue' }, }, undefined, options); expect(result.response.status).toBe(200); diff --git a/packages/provider-codex/package.json b/packages/provider-codex/package.json index 4849bfb22f..a6372a7441 100644 --- a/packages/provider-codex/package.json +++ b/packages/provider-codex/package.json @@ -17,6 +17,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@floway-dev/http": "workspace:*", + "@floway-dev/pipeline": "workspace:*", "@floway-dev/interceptor": "workspace:*", "@floway-dev/protocols": "workspace:*", "@floway-dev/provider": "workspace:*", diff --git a/packages/provider-codex/src/fetch.ts b/packages/provider-codex/src/fetch.ts index 7b94f23485..8d691b24c1 100644 --- a/packages/provider-codex/src/fetch.ts +++ b/packages/provider-codex/src/fetch.ts @@ -51,10 +51,10 @@ export interface CodexCallEffects { // Account selection shared by Codex backend calls. Every surface uses the same // OAuth credential, quota state, terminal-session classification, and refresh // retry contract; each operation owns its wire body and response decoding. -interface CodexBackendCallBase { +export interface CodexBackendCallBase { upstreamId: string; account: CodexAccountCredential; - model: ProviderModel; + model: Pick; headers: Headers; signal?: AbortSignal; effects: CodexCallEffects; @@ -137,7 +137,7 @@ export const callCodexOpenAIImagesEdits = async (opts: CallCodexOpenAIImagesEdit return await performImageCall(opts, request, prepared.accessToken, prepared.effectivePlan, false); }; -const prepareCodexCall = async (opts: CodexBackendCallBase): Promise<{ ok: true; accessToken: CodexAccessTokenEntry } | { ok: false; response: Response }> => { +export const prepareCodexCall = async (opts: CodexBackendCallBase): Promise<{ ok: true; accessToken: CodexAccessTokenEntry } | { ok: false; response: Response }> => { if (opts.account.state !== 'active') { return { ok: false, response: synthetic503(`Codex upstream is ${opts.account.state}`) }; } @@ -544,7 +544,7 @@ const dispatchCodexHttpCall = async ( return await classifyCodexHttpResponse(opts, response); }; -const classifyCodexHttpResponse = async ( +export const classifyCodexHttpResponse = async ( opts: CodexBackendCallBase, response: Response, quotaPolicy: 'always' | 'when-present' = 'always', @@ -560,25 +560,7 @@ const classifyCodexHttpResponse = async ( } if (response.status === 401) { - const bodyText = await response.text(); - const { code, message } = parseUpstreamError(bodyText); - if (opts.account.refresh_token === null) { - // An access-only credential cannot recover from a rejected bearer, so - // the row is marked best-effort — but a storage failure must never - // replace the upstream status, headers, or body the caller needs to - // diagnose what happened. - try { - await opts.effects.persistTerminalState('session_terminated', message); - } catch { - // The upstream response remains authoritative. - } - return new Response(bodyText, { status: 401, statusText: response.statusText, headers: response.headers }); - } - if (code === 'token_invalidated') { - await opts.effects.persistTerminalState('session_terminated', message); - return synthetic503(`Codex session terminated: ${message}`); - } - return new Response(bodyText, { status: 401, statusText: response.statusText, headers: response.headers }); + return await classifyCodexUnauthorizedResponse(opts, response, decodeCodexUpstreamError(await response.text())); } return response; @@ -620,7 +602,7 @@ const dispatchCodexImageCall = async ( // credential has nothing to re-mint from, its 401 was already classified as // terminal in `classifyCodexHttpResponse`, and the verbatim upstream response // is what reaches the client. -const refreshAccessTokenForRetry = async ( +export const refreshAccessTokenForRetry = async ( opts: CodexBackendCallBase, failedEntry: CodexAccessTokenEntry, fallbackPlan?: CodexPlanObservation, @@ -855,17 +837,45 @@ const performImageCall = async ( return { modelKey: opts.model.id, response }; }; -const parseUpstreamError = (rawText: string): { code: string | null; message: string } => { +export interface CodexUpstreamError { + readonly rawText: string; + readonly body: unknown; + readonly code: string | null; + readonly message: string; +} + +export const decodeCodexUpstreamError = (rawText: string): CodexUpstreamError => { + let body: unknown; try { - const obj = JSON.parse(rawText) as { error?: { code?: unknown; message?: unknown }; detail?: unknown }; - const code = obj.error && typeof obj.error === 'object' && typeof obj.error.code === 'string' ? obj.error.code : null; - const message = obj.error && typeof obj.error === 'object' && typeof obj.error.message === 'string' - ? obj.error.message - : typeof obj.detail === 'string' ? obj.detail : rawText.slice(0, 256); - return { code, message }; + body = JSON.parse(rawText); } catch { - return { code: null, message: rawText.slice(0, 256) }; + return { rawText, body: rawText, code: null, message: rawText.slice(0, 256) }; + } + const obj = typeof body === 'object' && body !== null ? body as { error?: { code?: unknown; message?: unknown }; detail?: unknown } : undefined; + const code = obj?.error && typeof obj.error === 'object' && typeof obj.error.code === 'string' ? obj.error.code : null; + const message = obj?.error && typeof obj.error === 'object' && typeof obj.error.message === 'string' + ? obj.error.message + : typeof obj?.detail === 'string' ? obj.detail : rawText.slice(0, 256); + return { rawText, body, code, message }; +}; + +export const classifyCodexUnauthorizedResponse = async (opts: CodexBackendCallBase, response: Response, parsed: CodexUpstreamError): Promise => { + const { rawText, code, message } = parsed; + if (opts.account.refresh_token === null) { + // Access-only credentials retain the authoritative upstream reply even when + // the terminal-state write fails. + try { + await opts.effects.persistTerminalState('session_terminated', message); + } catch { + // The upstream response remains authoritative. + } + return new Response(rawText, { status: 401, statusText: response.statusText, headers: response.headers }); + } + if (code === 'token_invalidated') { + await opts.effects.persistTerminalState('session_terminated', message); + return synthetic503(`Codex session terminated: ${message}`); } + return new Response(rawText, { status: 401, statusText: response.statusText, headers: response.headers }); }; const imageUnavailableResult = (modelKey: string): ProviderCallResult => ({ diff --git a/packages/provider-codex/src/index.ts b/packages/provider-codex/src/index.ts index 6f84a51d6b..b25ee2e346 100644 --- a/packages/provider-codex/src/index.ts +++ b/packages/provider-codex/src/index.ts @@ -1,6 +1,7 @@ import { CODEX_DEFAULT_FLAGS } from './defaults.ts'; import { createCodexProvider } from './provider.ts'; import type { ProviderModule } from '@floway-dev/provider'; +export { createCodexPipelines } from './pipelines.ts'; export const codexProviderModule: ProviderModule = { create: createCodexProvider, diff --git a/packages/provider-codex/src/models.ts b/packages/provider-codex/src/models.ts index dfbf9ae1ba..d844249fe8 100644 --- a/packages/provider-codex/src/models.ts +++ b/packages/provider-codex/src/models.ts @@ -141,7 +141,7 @@ export const codexModelContextWindow = (model: ProviderModel): CodexContextWindo }; }; -export const codexModelUsesResponsesLite = (model: ProviderModel): boolean => { +export const codexModelUsesResponsesLite = (model: Pick): boolean => { const providerData = model.providerData; if (providerData === undefined) return false; if (!isPlainRecord(providerData)) { diff --git a/packages/provider-codex/src/pipeline-facts.ts b/packages/provider-codex/src/pipeline-facts.ts new file mode 100644 index 0000000000..7edaa35088 --- /dev/null +++ b/packages/provider-codex/src/pipeline-facts.ts @@ -0,0 +1,65 @@ +import type { CodexPlanObservation } from './access-token.ts'; +import type { CodexCallEffects, CodexBackendCallBase } from './fetch.ts'; +import type { CodexAccountCredential, CodexAccessTokenEntry } from './state.ts'; +import type { HttpRequestFacts } from '@floway-dev/http/pipeline'; +import { secret, type Secret } from '@floway-dev/pipeline'; +import type { ProviderModelFacts, ProviderOperationPayloads, ProviderRequest, ProviderServices } from '@floway-dev/provider'; + +export type CodexOperation = 'alphaSearch' | 'openaiImagesGenerations' | 'openaiImagesEdits'; +export type CodexRequest = ProviderRequest; +export type CodexToken = Omit & { readonly token: Secret }; +export type CodexAccount = Omit & { + readonly refresh_token: Secret | null; + readonly accessToken: CodexToken | null; +}; +export type CodexAccountFacts = CodexRequest & { + 'request.codex.account': CodexAccount; +}; +export type CodexAuthenticatedFacts = CodexAccountFacts & { + 'request.codex.accessToken': CodexToken; + 'request.codex.plan': CodexPlanObservation | null; +}; +export interface CodexHttpFacts extends HttpRequestFacts { + 'request.codex.model': ProviderModelFacts; + 'request.codex.account': CodexAccount; + 'request.codex.accessToken': CodexToken; + 'request.codex.plan': CodexPlanObservation | null; + 'request.codex.modelKey': string; +} + +export interface CodexPipelineConfig { + readonly upstreamId: string; + readonly fallbackPlanType: string | undefined; + readonly readAccount: () => Promise; + readonly effects: CodexCallEffects; +} + +export const tokenFacts = (entry: CodexAccessTokenEntry): CodexToken => ({ ...entry, token: secret(entry.token) }); +export const accountFacts = (account: CodexAccountCredential): CodexAccount => ({ + ...account, + refresh_token: account.refresh_token === null ? null : secret(account.refresh_token), + accessToken: account.accessToken === null ? null : tokenFacts(account.accessToken), +}); +export const accountValue = (account: CodexAccount): CodexAccountCredential => ({ + ...account, + refresh_token: account.refresh_token === null ? null : account.refresh_token.reveal(), + accessToken: account.accessToken === null ? null : { ...account.accessToken, token: account.accessToken.token.reveal() }, +}); + +export const codexCall = ( + config: CodexPipelineConfig, + facts: { 'request.codex.account': CodexAccount; 'request.http.callId': number }, + use: ProviderServices, + model: CodexBackendCallBase['model'], +): CodexBackendCallBase => { + const call = use.httpCall(facts['request.http.callId']); + return { + upstreamId: config.upstreamId, + account: accountValue(facts['request.codex.account']), + model, + headers: new Headers(), + signal: call.signal, + effects: config.effects, + call: { ...call, headers: new Headers() }, + }; +}; diff --git a/packages/provider-codex/src/pipelines.ts b/packages/provider-codex/src/pipelines.ts new file mode 100644 index 0000000000..330a78d19f --- /dev/null +++ b/packages/provider-codex/src/pipelines.ts @@ -0,0 +1,16 @@ +import type { CodexPipelineConfig, CodexRequest } from './pipeline-facts.ts'; +import { authenticateCodex } from './stages/authenticate.ts'; +import { authorizeCodexHeaders } from './stages/authorize-headers.ts'; +import { observeCodexResponse } from './stages/observe-response.ts'; +import { prepareCodexRequest } from './stages/prepare-request.ts'; +import { readCodexAccount } from './stages/read-account.ts'; +import { retryCodexAccess } from './stages/retry-access.ts'; +import { http } from '@floway-dev/http/pipeline'; +import { compose } from '@floway-dev/pipeline'; +import type { ProviderPipelines, ProviderResponse } from '@floway-dev/provider'; + +export const createCodexPipelines = (config: CodexPipelineConfig): ProviderPipelines => ({ + alphaSearch: compose, ProviderResponse>('codex.alphaSearch', [readCodexAccount<'alphaSearch'>(config), authenticateCodex(config, 'alphaSearch'), prepareCodexRequest('alphaSearch'), retryCodexAccess(config, 'alphaSearch'), authorizeCodexHeaders, observeCodexResponse(config, 'alphaSearch'), http]), + openaiImagesGenerations: compose, ProviderResponse>('codex.openaiImagesGenerations', [readCodexAccount<'openaiImagesGenerations'>(config), authenticateCodex(config, 'openaiImagesGenerations'), prepareCodexRequest('openaiImagesGenerations'), retryCodexAccess(config, 'openaiImagesGenerations'), authorizeCodexHeaders, observeCodexResponse(config, 'openaiImagesGenerations'), http]), + openaiImagesEdits: compose, ProviderResponse>('codex.openaiImagesEdits', [readCodexAccount<'openaiImagesEdits'>(config), authenticateCodex(config, 'openaiImagesEdits'), prepareCodexRequest('openaiImagesEdits'), retryCodexAccess(config, 'openaiImagesEdits'), authorizeCodexHeaders, observeCodexResponse(config, 'openaiImagesEdits'), http]), +}); diff --git a/packages/provider-codex/src/provider.ts b/packages/provider-codex/src/provider.ts index b5682bc0a0..ed8b0e80c5 100644 --- a/packages/provider-codex/src/provider.ts +++ b/packages/provider-codex/src/provider.ts @@ -6,6 +6,7 @@ import { callCodexAlphaSearch, callCodexOpenAIImagesEdits, callCodexOpenAIImages import { CODEX_OPENAI_RESPONSES_BOUNDARY } from './interceptors/openai-responses/index.ts'; import type { OpenAIResponsesBoundaryCtx } from './interceptors/openai-responses/types.ts'; import { codexImageProviderModel, codexPlanSupportsImages, codexRawToProviderModel, fetchCodexCatalog } from './models.ts'; +import { createCodexPipelines } from './pipelines.ts'; import { assertCodexUpstreamState, findCodexAccountIndex, persistCodexRefreshTokenRotation, persistCodexTerminalState } from './state.ts'; import { runCodexScheduledTask } from './usage-probe.ts'; import { runInterceptors } from '@floway-dev/interceptor'; @@ -161,7 +162,6 @@ export const createCodexProvider = (record: UpstreamRecord): Provider => { }, callOpenAIAudioTranscriptions: () => unsupportedCallResult(), callRerank: () => Promise.reject(new Error('Codex provider does not support callRerank')), - callDecision: () => Promise.reject(new Error('Codex provider does not support callDecision')), }; return { @@ -172,6 +172,7 @@ export const createCodexProvider = (record: UpstreamRecord): Provider => { disabledPublicModelIds: record.disabledPublicModelIds, modelPrefix: record.modelPrefix, modelsCache: record.modelsCache, + pipelines: createCodexPipelines({ upstreamId: record.id, fallbackPlanType: accountIdentity.planType ?? undefined, readAccount: async () => (await readActiveAccount()).account, effects }), instance, }; }; diff --git a/packages/provider-codex/src/stages/authenticate.ts b/packages/provider-codex/src/stages/authenticate.ts new file mode 100644 index 0000000000..0adb1dbe20 --- /dev/null +++ b/packages/provider-codex/src/stages/authenticate.ts @@ -0,0 +1,31 @@ +import { codexPlanObservation } from '../access-token.ts'; +import { prepareCodexCall } from '../fetch.ts'; +import { codexPlanSupportsImages } from '../models.ts'; +import { codexCall, tokenFacts, type CodexAccountFacts, type CodexAuthenticatedFacts, type CodexOperation, type CodexPipelineConfig } from '../pipeline-facts.ts'; +import { takeHttpResponse } from '@floway-dev/http/pipeline'; +import { defineStage, move, type Handed } from '@floway-dev/pipeline'; +import type { ProviderResponse, ProviderServices } from '@floway-dev/provider'; + +export const authenticateCodex = (config: CodexPipelineConfig, operation: O) => defineStage, CodexAuthenticatedFacts, ProviderResponse, ProviderResponse, ProviderResponse, ProviderServices>({ + name: 'authenticateCodex', + through: { + request: { needs: ['request.codex.account', 'request.provider.model', 'request.http.callId'], consumes: [], provides: ['request.codex.accessToken', 'request.codex.plan'] }, + response: { needs: [], consumes: [], provides: [] }, + }, + return: { provides: ['response.http.exchange', 'response.http.body', 'response.provider.modelKey', 'response.provider.called', 'response.provider.previousCalls'] }, + execute: async (facts, next, use) => { + const model = facts['request.provider.model']; + const ready = await prepareCodexCall(codexCall(config, facts, use, model)); + const failed = (response: Response) => { + const exchange = takeHttpResponse(response); + return move({ ...facts, 'response.http.exchange': exchange, 'response.http.body': exchange.body, 'response.provider.modelKey': model.id, 'response.provider.called': false, 'response.provider.previousCalls': [] }); + }; + if (!ready.ok) return failed(ready.response); + const observedPlan = codexPlanObservation(ready.accessToken); + const plan = observedPlan ?? (config.fallbackPlanType === undefined ? null : { planType: config.fallbackPlanType }); + if (operation !== 'alphaSearch' && plan !== null && !codexPlanSupportsImages(plan.planType)) { + return failed(Response.json({ error: { type: 'image_tools_unavailable', message: 'ChatGPT Free accounts do not provide Codex image tools.' } }, { status: 403 })); + } + return await next(move({ ...facts, 'request.codex.accessToken': tokenFacts(ready.accessToken), 'request.codex.plan': plan })) as Handed; + }, +}); diff --git a/packages/provider-codex/src/stages/authorize-headers.ts b/packages/provider-codex/src/stages/authorize-headers.ts new file mode 100644 index 0000000000..5ae01de3a9 --- /dev/null +++ b/packages/provider-codex/src/stages/authorize-headers.ts @@ -0,0 +1,22 @@ +import type { CodexHttpFacts } from '../pipeline-facts.ts'; +import type { HttpResponseFacts } from '@floway-dev/http/pipeline'; +import { defineStage, move, secret, type Handed } from '@floway-dev/pipeline'; +import { replaceHttpHeader, type ProviderServices } from '@floway-dev/provider'; + +export const authorizeCodexHeaders = defineStage({ + name: 'authorizeCodexHeaders', + through: { + request: { + needs: ['request.codex.account', 'request.codex.accessToken', 'request.http.headers'], + consumes: ['request.http.headers'], + provides: ['request.http.headers'], + }, + response: { needs: [], consumes: [], provides: [] }, + }, + execute: async (facts, next) => { + const accountId = facts['request.codex.account'].chatgptAccountId; + let headers = replaceHttpHeader(facts['request.http.headers'], 'authorization', secret(`Bearer ${facts['request.codex.accessToken'].token.reveal()}`)); + if (accountId !== null) headers = replaceHttpHeader(headers, 'chatgpt-account-id', accountId); + return await next(move({ ...facts, 'request.http.headers': headers })) as Handed; + }, +}); diff --git a/packages/provider-codex/src/stages/observe-response.ts b/packages/provider-codex/src/stages/observe-response.ts new file mode 100644 index 0000000000..72078ff26a --- /dev/null +++ b/packages/provider-codex/src/stages/observe-response.ts @@ -0,0 +1,35 @@ +import { classifyCodexHttpResponse, classifyCodexUnauthorizedResponse, decodeCodexUpstreamError } from '../fetch.ts'; +import { codexCall, type CodexHttpFacts, type CodexOperation, type CodexPipelineConfig } from '../pipeline-facts.ts'; +import { exchangeResponse, takeHttpResponse, type HttpResponseFacts } from '@floway-dev/http/pipeline'; +import { defer, defineStage, move, setRelease, type Deferred } from '@floway-dev/pipeline'; +import type { ProviderServices } from '@floway-dev/provider'; + +type Observed = HttpResponseFacts & { 'response.codex.background': Deferred; 'response.codex.failureBody': unknown }; + +export const observeCodexResponse = (config: CodexPipelineConfig, operation: CodexOperation) => defineStage({ + name: 'observeCodexResponse', + through: { + request: { needs: ['request.codex.account', 'request.codex.model', 'request.http.callId'], consumes: [], provides: [] }, + response: { needs: ['response.http.exchange', 'response.http.body'], consumes: ['response.http.exchange', 'response.http.body'], provides: ['response.http.exchange', 'response.http.body', 'response.codex.background', 'response.codex.failureBody'] }, + }, + execute: async (facts, next, use) => { + const back = await next(move({ ...facts })); + const exchange = back['response.http.exchange']; + const pending: Promise[] = []; + if (exchange.type === 'transportFailure') return move({ ...back, 'response.codex.background': defer(Promise.all(pending)), 'response.codex.failureBody': null }); + const call = codexCall(config, facts, use, facts['request.codex.model']); + const response = exchangeResponse(exchange); + let classified: Response; + let failureBody: unknown = null; + if (exchange.status === 401) { + const parsed = decodeCodexUpstreamError(await response.text()); + if (exchange.body !== null) setRelease(exchange.body, async () => {}); + failureBody = parsed.body; + classified = await classifyCodexUnauthorizedResponse(call, response, parsed); + } else { + classified = await classifyCodexHttpResponse({ ...call, call: { ...call.call, waitUntil: work => { pending.push(work); } } }, response, operation === 'alphaSearch' ? 'always' : 'when-present'); + } + const retained = classified === response ? exchange : takeHttpResponse(classified); + return move({ ...back, 'response.http.exchange': retained, 'response.http.body': retained.body, 'response.codex.background': defer(Promise.all(pending)), 'response.codex.failureBody': failureBody }); + }, +}); diff --git a/packages/provider-codex/src/stages/prepare-request.ts b/packages/provider-codex/src/stages/prepare-request.ts new file mode 100644 index 0000000000..993fb4c0b6 --- /dev/null +++ b/packages/provider-codex/src/stages/prepare-request.ts @@ -0,0 +1,61 @@ +import { CODEX_ALPHA_SEARCH_PATH, CODEX_BACKEND_BASE, CODEX_CLI_VERSION, CODEX_OPENAI_IMAGES_EDITS_PATH, CODEX_OPENAI_IMAGES_GENERATIONS_PATH, CODEX_ORIGINATOR, CODEX_USER_AGENT } from '../constants.ts'; +import { uuidV7 } from '../ids.ts'; +import type { CodexAuthenticatedFacts, CodexHttpFacts, CodexOperation } from '../pipeline-facts.ts'; +import { defineStage, isSecret, move } from '@floway-dev/pipeline'; +import { serializeOpenAIImagesEditsJsonPayload, type ProviderCallResponse, type ProviderOperationPayloads, type ProviderResponse, type ProviderServices } from '@floway-dev/provider'; + +const header = (facts: CodexAuthenticatedFacts, name: string): string | null => { + const value = facts['request.http.headers'].find(([key]) => key.toLowerCase() === name)?.[1]; + const text = value === undefined ? '' : isSecret(value) ? value.reveal() : value; + return text.trim() || null; +}; + +export const prepareCodexRequest = (operation: O) => defineStage, CodexHttpFacts, ProviderCallResponse, ProviderResponse, ProviderServices>({ + name: `prepareCodex${operation.replace(/^openai/, 'OpenAI').replace(/^alpha/, 'Alpha')}`, + through: { + request: { + needs: ['request.provider.model', 'request.provider.payload', 'request.http.headers', 'request.http.callId', 'request.codex.account', 'request.codex.accessToken', 'request.codex.plan'], + consumes: ['request.provider.model', 'request.provider.payload', 'request.http.headers'], + provides: ['request.http.url', 'request.http.method', 'request.http.headers', 'request.http.body', 'request.http.encoding', 'request.codex.modelKey', 'request.codex.model'], + }, + response: { needs: ['response.http.exchange', 'response.provider.called', 'response.provider.previousCalls'], consumes: [], provides: ['response.provider.modelKey'] }, + }, + execute: async (facts, next) => { + const { 'request.provider.model': model, 'request.provider.payload': payload, ...rest } = facts; + let body: Record; + let path: string; + const headers: [string, string][] = [ + ['originator', operation === 'alphaSearch' ? CODEX_ORIGINATOR : header(facts, 'originator') ?? CODEX_ORIGINATOR], + ['user-agent', CODEX_USER_AGENT], + ['version', CODEX_CLI_VERSION], + ['accept', 'application/json'], + ['content-type', 'application/json'], + ]; + if (operation === 'alphaSearch') { + const source = payload as ProviderOperationPayloads['alphaSearch']; + const requestId = typeof source.id === 'string' && source.id.trim().length > 0 ? source.id.trim() : uuidV7(); + body = { ...source, id: requestId, model: model.id }; + path = CODEX_ALPHA_SEARCH_PATH; + headers.push(['session-id', requestId], ['thread-id', requestId], ['x-client-request-id', requestId], ['x-codex-window-id', `${requestId}:0`]); + const metadata = header(facts, 'x-codex-turn-metadata'); + if (metadata !== null) headers.push(['x-codex-turn-metadata', metadata]); + } else { + body = operation === 'openaiImagesEdits' + ? await serializeOpenAIImagesEditsJsonPayload(payload as ProviderOperationPayloads['openaiImagesEdits'], model.id) + : { ...payload, model: model.id }; + path = operation === 'openaiImagesEdits' ? CODEX_OPENAI_IMAGES_EDITS_PATH : CODEX_OPENAI_IMAGES_GENERATIONS_PATH; + headers.push(['x-codex-image-turn-id', header(facts, 'x-codex-image-turn-id') ?? uuidV7()]); + } + const back = await next(move({ + ...rest, + 'request.codex.model': model, + 'request.codex.modelKey': model.id, + 'request.http.url': `${CODEX_BACKEND_BASE}${path}`, + 'request.http.method': 'POST', + 'request.http.headers': headers, + 'request.http.body': body, + 'request.http.encoding': 'json' as const, + })); + return move({ ...back, 'response.provider.modelKey': model.id }); + }, +}); diff --git a/packages/provider-codex/src/stages/read-account.ts b/packages/provider-codex/src/stages/read-account.ts new file mode 100644 index 0000000000..5900f933a3 --- /dev/null +++ b/packages/provider-codex/src/stages/read-account.ts @@ -0,0 +1,12 @@ +import { accountFacts, type CodexAccountFacts, type CodexOperation, type CodexPipelineConfig, type CodexRequest } from '../pipeline-facts.ts'; +import { defineStage, move, type Handed } from '@floway-dev/pipeline'; +import type { ProviderResponse, ProviderServices } from '@floway-dev/provider'; + +export const readCodexAccount = (config: CodexPipelineConfig) => defineStage, CodexAccountFacts, ProviderResponse, ProviderResponse, ProviderServices>({ + name: 'readCodexAccount', + through: { + request: { needs: [], consumes: [], provides: ['request.codex.account'] }, + response: { needs: [], consumes: [], provides: [] }, + }, + execute: async (facts, next) => await next(move({ ...facts, 'request.codex.account': accountFacts(await config.readAccount()) })) as Handed, +}); diff --git a/packages/provider-codex/src/stages/retry-access.ts b/packages/provider-codex/src/stages/retry-access.ts new file mode 100644 index 0000000000..da73e6a0fc --- /dev/null +++ b/packages/provider-codex/src/stages/retry-access.ts @@ -0,0 +1,32 @@ +import { codexPlanObservation } from '../access-token.ts'; +import { refreshAccessTokenForRetry } from '../fetch.ts'; +import { codexPlanSupportsImages } from '../models.ts'; +import { codexCall, tokenFacts, type CodexHttpFacts, type CodexOperation, type CodexPipelineConfig } from '../pipeline-facts.ts'; +import { takeHttpResponse, type HttpResponseFacts } from '@floway-dev/http/pipeline'; +import { defineStage, move } from '@floway-dev/pipeline'; +import type { ProviderCallResponse, ProviderServices } from '@floway-dev/provider'; + +export const retryCodexAccess = (config: CodexPipelineConfig, operation: CodexOperation) => defineStage({ + name: 'retryCodexAccess', + through: { + request: { needs: ['request.codex.account', 'request.codex.accessToken', 'request.codex.plan', 'request.codex.model', 'request.codex.modelKey', 'request.http.callId'], consumes: ['request.codex.accessToken', 'request.codex.plan'], provides: ['request.codex.accessToken', 'request.codex.plan'] }, + response: { needs: ['response.http.exchange', 'response.http.body'], consumes: ['response.http.exchange', 'response.http.body'], provides: ['response.http.exchange', 'response.http.body', 'response.provider.called', 'response.provider.previousCalls'] }, + }, + execute: async (facts, next, use) => { + const first = await next(move({ ...facts })); + const exchange = first['response.http.exchange']; + if (exchange.type !== 'response' || exchange.status !== 401 || facts['request.codex.account'].refresh_token === null) return move({ ...first, 'response.provider.called': exchange.type === 'response', 'response.provider.previousCalls': [] }); + if (exchange.body !== null) await exchange.body[Symbol.asyncDispose](); + const failedToken = facts['request.codex.accessToken']; + const fresh = await refreshAccessTokenForRetry(codexCall(config, facts, use, facts['request.codex.model']), { ...failedToken, token: failedToken.token.reveal() }, facts['request.codex.plan'] ?? undefined); + const declined = (response: Response) => { + const nextExchange = takeHttpResponse(response); + return move({ ...first, 'response.http.exchange': nextExchange, 'response.http.body': nextExchange.body, 'response.provider.called': false, 'response.provider.previousCalls': [{ modelKey: facts['request.codex.modelKey'] }] }); + }; + if (!fresh.ok) return declined(fresh.response); + const plan = codexPlanObservation(fresh.accessToken) ?? facts['request.codex.plan']; + if (operation !== 'alphaSearch' && plan !== null && !codexPlanSupportsImages(plan.planType)) return declined(Response.json({ error: { type: 'image_tools_unavailable', message: 'ChatGPT Free accounts do not provide Codex image tools.' } }, { status: 403 })); + const retried = await next(move({ ...facts, 'request.codex.accessToken': tokenFacts(fresh.accessToken), 'request.codex.plan': plan })); + return move({ ...retried, 'response.provider.called': retried['response.http.exchange'].type === 'response', 'response.provider.previousCalls': [{ modelKey: facts['request.codex.modelKey'] }] }); + }, +}); diff --git a/packages/provider-copilot/__tests__/provider_test.ts b/packages/provider-copilot/__tests__/provider_test.ts index 482c86bae4..1f8286bb62 100644 --- a/packages/provider-copilot/__tests__/provider_test.ts +++ b/packages/provider-copilot/__tests__/provider_test.ts @@ -11,7 +11,7 @@ import type { AnthropicMessagesPayload } from '@floway-dev/protocols/anthropic-m import { parseDecisionRequest } from '@floway-dev/protocols/decision'; import type { UpstreamRecord } from '@floway-dev/provider'; import { directFetcher, initProviderRepo } from '@floway-dev/provider'; -import { assertEquals, assertRejects, assertThrows, jsonResponse, noopAnthropicMessagesUpstreamCallOptions, noopUpstreamCallOptions, sseResponse, withMockedFetch } from '@floway-dev/test-utils'; +import { callProviderPipeline, assertEquals, assertRejects, assertThrows, jsonResponse, noopAnthropicMessagesUpstreamCallOptions, noopUpstreamCallOptions, sseResponse, withMockedFetch } from '@floway-dev/test-utils'; const mergeVariantsControl = vi.hoisted<{ override: ((merged: CopilotRawModel[]) => CopilotRawModel[]) | null; @@ -598,7 +598,7 @@ test('Copilot provider forces stream=true for streaming endpoints and leaves cou await provider.callOpenAIResponses(byId.get('gpt-resp')!, { input: [] }, 'generate', undefined, opts); await provider.callAnthropicMessages(byId.get('claude-msg')!, { max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] }, undefined, anthropicMessagesOpts); await provider.callAnthropicMessagesCountTokens(byId.get('claude-msg')!, { max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] }, undefined, anthropicMessagesOpts); - await provider.callOpenAIEmbeddings(byId.get('emb-mini')!, { input: 'hi' }, undefined, opts); + await callProviderPipeline(instance, 'openaiEmbeddings', byId.get('emb-mini')!, { input: 'hi' }, undefined, opts); }, ); @@ -1350,7 +1350,7 @@ const imageModelCatalog = () => copilotModelsWithCapabilities([ const setupImageModelTest = async () => { const harness = await setupCopilotTest(); - const provider = createCopilotProvider(harness.copilotUpstream).instance; + const provider = createCopilotProvider(harness.copilotUpstream); return { harness, provider }; }; @@ -1374,8 +1374,8 @@ test('Copilot image generations POSTs JSON to the v1 path with the raw model id' return jsonResponse({ created: 1, data: [{ b64_json: 'AAAA' }] }); }, async () => { - const [model] = await provider.getProvidedModels(directFetcher); - const result = await provider.callOpenAIImagesGenerations(model, { prompt: 'a red square' }, undefined, noopUpstreamCallOptions()); + const [model] = await provider.instance.getProvidedModels(directFetcher); + const result = await callProviderPipeline(provider, 'openaiImagesGenerations', model, { prompt: 'a red square' }); assertEquals(result.response.status, 200); }, ); @@ -1386,11 +1386,9 @@ test('Copilot image generations POSTs JSON to the v1 path with the raw model id' assertEquals(captured?.body.model, 'gpt-image-2.5-flare'); }); -// The regression this guards: `copilotAuthedFetch` used to pin -// `Content-Type: application/json` unconditionally, which made Copilot answer -// a multipart edit with `400 Failed to decode image edit request` before -// reading a single part. A FormData body must reach the runtime untouched so -// it can set its own boundary. +// Copilot answers a multipart edit carrying `Content-Type: application/json` +// with `400 Failed to decode image edit request` before reading a single part, +// so an upload must be sent with its own multipart boundary. test('Copilot image edits keeps multipart uploads free of a JSON content-type', async () => { const { provider } = await setupImageModelTest(); let captured: { path: string; contentType: string | null; body: string } | undefined; @@ -1411,9 +1409,9 @@ test('Copilot image edits keeps multipart uploads free of a JSON content-type', return jsonResponse({ created: 1, data: [{ b64_json: 'AAAA' }] }); }, async () => { - const [model] = await provider.getProvidedModels(directFetcher); - const file = new File([new Uint8Array([0x89, 0x50, 0x4E, 0x47])], 'in.png', { type: 'image/png' }); - const result = await provider.callOpenAIImagesEdits(model, { images: [{ type: 'upload', file }], parameters: { prompt: 'make it blue' } }, undefined, noopUpstreamCallOptions()); + const [model] = await provider.instance.getProvidedModels(directFetcher); + const file = { bytes: new Uint8Array([0x89, 0x50, 0x4E, 0x47]), name: 'in.png', type: 'image/png' }; + const result = await callProviderPipeline(provider, 'openaiImagesEdits', model, { images: [{ type: 'upload', file }], parameters: { prompt: 'make it blue' } }); assertEquals(result.response.status, 200); }, ); @@ -1607,8 +1605,7 @@ test('Copilot provider merges the -fast raw variant and reaches it through servi test('Copilot provider derives decision endpoints and forwards the System One body', async () => { const { copilotUpstream } = await setupCopilotTest(); - const instance = createCopilotProvider(copilotUpstream); - const provider = instance.instance; + const provider = createCopilotProvider(copilotUpstream); const sent: Record[] = []; await withMockedFetch( @@ -1646,7 +1643,7 @@ test('Copilot provider derives decision endpoints and forwards the System One bo throw new Error(`Unhandled fetch ${request.url}`); }, async () => { - const models = await provider.getProvidedModels(directFetcher); + const models = await provider.instance.getProvidedModels(directFetcher); const decision = models.find(model => model.id === 'microsoft-decision-1')!; assertEquals(decision.kind, 'decision'); assertEquals(decision.endpoints, { decision: {} }); @@ -1657,8 +1654,7 @@ test('Copilot provider derives decision endpoints and forwards the System One bo state: 'The API returns 500 on every call.', questions: { team: { type: 'choice', instructions: 'Which team?', criteria: { billing: 'Charges', engineering: 'Bugs' } } }, }); - const result = await provider.callDecision(decision, parsed.request, undefined, noopUpstreamCallOptions()); - assertEquals(result.target, { protocol: 'typesafe-systemone' }); + const result = await callProviderPipeline(provider, 'decision', decision, parsed.request); assertEquals(result.response.status, 200); }, ); @@ -1671,7 +1667,7 @@ test('Copilot provider derives decision endpoints and forwards the System One bo test('Copilot sends an OpenAI Decisions model to /v1/decisions in its own dialect', async () => { const { copilotUpstream } = await setupCopilotTest(); - const provider = createCopilotProvider(copilotUpstream).instance; + const provider = createCopilotProvider(copilotUpstream); const sent: { path: string; body: Record }[] = []; await withMockedFetch( @@ -1695,14 +1691,14 @@ test('Copilot sends an OpenAI Decisions model to /v1/decisions in its own dialec throw new Error(`Unhandled fetch ${request.url}`); }, async () => { - const decision = (await provider.getProvidedModels(directFetcher))[0]!; + const decision = (await provider.instance.getProvidedModels(directFetcher))[0]!; const parsed = parseDecisionRequest('openai-decisions', { model: 'gpt-6-luna-decisions', input: 'The API returns 500 on every call.', questions: [{ type: 'predicate', name: 'p', instructions: 'Is this a bug?' }], }); - const result = await provider.callDecision(decision, parsed.request, undefined, noopUpstreamCallOptions()); - assertEquals(result.target, { protocol: 'openai-decisions' }); + assertEquals(decision.decisionTarget, { protocol: 'openai-decisions' }); + const result = await callProviderPipeline(provider, 'decision', decision, parsed.request); assertEquals(result.response.status, 200); }, ); diff --git a/packages/provider-copilot/__tests__/stages/observe-quota_test.ts b/packages/provider-copilot/__tests__/stages/observe-quota_test.ts new file mode 100644 index 0000000000..5aa58a2574 --- /dev/null +++ b/packages/provider-copilot/__tests__/stages/observe-quota_test.ts @@ -0,0 +1,36 @@ +import { expect, test } from 'vitest'; + +import { observeCopilotQuota } from '../../src/stages/observe-quota.ts'; +import type { HttpRequestFacts, HttpResponseFacts } from '@floway-dev/http/pipeline'; +import { compose, defineStage, move, run } from '@floway-dev/pipeline'; +import { initProviderRepo, type ProviderServices } from '@floway-dev/provider'; +import { noopUpstreamCallOptions } from '@floway-dev/test-utils'; + +test('returns the HTTP reply while quota persistence rejects the deferred drain with its original error', async () => { + const error = new Error('quota persistence failed'); + let writes = 0; + initProviderRepo(() => ({ + upstreams: { + getById: async () => null, + saveState: async () => { writes++; throw error; }, + }, + recordUpstreamMetric: async () => { throw new Error('quota persistence failed before metrics'); }, + })); + const exchange = move({ + type: 'response' as const, + status: 200, + statusText: 'OK', + headers: [['x-quota-snapshot-chat', 'ent=-1&ov=0.0&ovPerm=false&rem=100.0&rst=2026-09-01T00%3A00%3A00Z&totRem=-1']] as const, + body: null, + }); + const terminal = defineStage, HttpResponseFacts>({ + name: 'reply', + return: { provides: ['response.http.exchange', 'response.http.body'] }, + execute: async facts => move({ ...facts, 'response.http.exchange': exchange, 'response.http.body': null }), + }); + const services: ProviderServices = { httpCall: () => ({ ...noopUpstreamCallOptions(), signal: undefined }) }; + const executed = await run(compose, HttpResponseFacts>('quota', [observeCopilotQuota('up_quota'), terminal]), move({ 'request.http.callId': 1 }), services); + expect(executed.facts['response.http.exchange']).toBe(exchange); + expect(writes).toBe(1); + await expect(executed.drain()).rejects.toBe(error); +}); diff --git a/packages/provider-copilot/package.json b/packages/provider-copilot/package.json index 5d616cb874..7d69b0a6a6 100644 --- a/packages/provider-copilot/package.json +++ b/packages/provider-copilot/package.json @@ -17,6 +17,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@floway-dev/http": "workspace:*", + "@floway-dev/pipeline": "workspace:*", "@floway-dev/interceptor": "workspace:*", "@floway-dev/platform": "workspace:*", "@floway-dev/protocols": "workspace:*", diff --git a/packages/provider-copilot/src/auth.ts b/packages/provider-copilot/src/auth.ts index 73f0e07a52..d1f6964cd5 100644 --- a/packages/provider-copilot/src/auth.ts +++ b/packages/provider-copilot/src/auth.ts @@ -127,7 +127,7 @@ function isTokenValid(token: string | null, expiresAt: number): boolean { return expiresAt > now + 60; } -async function getCopilotToken(upstreamId: string, githubHost: string, githubToken: string, fetcher: Fetcher, signal: AbortSignal | undefined): Promise { +export async function getCopilotToken(upstreamId: string, githubHost: string, githubToken: string, fetcher: Fetcher, signal: AbortSignal | undefined): Promise { const now = Date.now(); const cached = inProcessTokenCache.get(upstreamId); if (cached && isTokenValid(cached.entry.token, cached.entry.expiresAt) && now - cached.cachedAt < IN_PROCESS_TTL_MS) { @@ -225,30 +225,13 @@ export interface CopilotAuth { githubToken: string; } -export async function copilotAuthedFetch(path: string, init: FetchInit, auth: CopilotAuth, options: CopilotFetchOptions): Promise { - const signal = init.signal ?? undefined; - let ownedInit: FetchInit | undefined = init; - // The token exchange is the only await before the data-plane dispatch. Keep - // the body in an explicit owner and replace the generator parameter so the - // final network wait cannot retain both copies after ownership transfers. - init = { signal }; - const entry = await getCopilotToken(auth.id, auth.githubHost, auth.githubToken, options.fetcher, signal); - +export const copilotRequestHeaders = (entry: CopilotTokenEntry, initHeaders: HeadersInit | undefined, attached: HttpHeaderLines | undefined): Headers => { // x-request-id and x-agent-task-id share a single per-call UUID, mirroring // VSCode Copilot Chat's "one id ties the request to its background task" pattern. const requestId = crypto.randomUUID(); - if (ownedInit === undefined) throw new Error('Copilot request ownership missing before dispatch'); - const headers = new Headers(ownedInit.headers); + const headers = new Headers(initHeaders); headers.set('Authorization', `Bearer ${entry.token}`); - // Content-Type follows the body. A `FormData` body has to reach the runtime - // untouched so it can generate the multipart boundary itself — pinning - // `application/json` over it makes Copilot reject `/v1/images/edits` with - // `400 Failed to decode image edit request` before it reads a single part. - // A bodyless GET (the catalog) carries no Content-Type, matching VSCode. - if (ownedInit.body !== undefined && ownedInit.body !== null && !(ownedInit.body instanceof FormData)) { - headers.set('Content-Type', 'application/json'); - } headers.set('editor-version', EDITOR_VERSION); headers.set('editor-plugin-version', EDITOR_PLUGIN_VERSION); headers.set('editor-device-id', getEditorDeviceId()); @@ -272,13 +255,36 @@ export async function copilotAuthedFetch(path: string, init: FetchInit, auth: Co // otherwise pin. An interceptor that wants to clear an arbitrary downstream // header value must do so by name through this sentinel; the layer does not // otherwise expose a per-header delete API. - if (options.headers) { - for (const [name, value] of options.headers) { + if (attached) { + for (const [name, value] of attached) { if (value === '') headers.delete(name); else headers.set(name, value); } } + return headers; +}; + +export async function copilotAuthedFetch(path: string, init: FetchInit, auth: CopilotAuth, options: CopilotFetchOptions): Promise { + const signal = init.signal ?? undefined; + let ownedInit: FetchInit | undefined = init; + // The token exchange is the only await before the data-plane dispatch. Keep + // the body in an explicit owner and replace the generator parameter so the + // final network wait cannot retain both copies after ownership transfers. + init = { signal }; + const entry = await getCopilotToken(auth.id, auth.githubHost, auth.githubToken, options.fetcher, signal); + + if (ownedInit === undefined) throw new Error('Copilot request ownership missing before dispatch'); + // Content-Type follows the body. A `FormData` body has to reach the runtime + // untouched so it can generate the multipart boundary itself — pinning + // `application/json` over it makes Copilot reject `/v1/images/edits` with + // `400 Failed to decode image edit request` before it reads a single part. + // A bodyless GET (the catalog) carries no Content-Type, matching VSCode. + const initHeaders = new Headers(ownedInit.headers); + const body = ownedInit.body; + if (body !== undefined && body !== null && !(body instanceof FormData)) initHeaders.set('Content-Type', 'application/json'); + const headers = copilotRequestHeaders(entry, initHeaders, options.headers); + const request = { ...ownedInit, headers }; ownedInit = undefined; // Do not await here: the dispatch owner clears its body synchronously, then diff --git a/packages/provider-copilot/src/fetch.ts b/packages/provider-copilot/src/fetch.ts index 9200911bfe..c6d6912743 100644 --- a/packages/provider-copilot/src/fetch.ts +++ b/packages/provider-copilot/src/fetch.ts @@ -84,8 +84,3 @@ export const copilotFetchOpenAIImagesEdits = (config: CopilotFetchConfig, init: copilotFetchInternal(config, '/v1/images/edits', init, options); export const copilotFetchModels = (config: CopilotFetchConfig, init: FetchInit, options: CopilotDataPlaneFetchOptions): Promise => copilotFetchInternal(config, '/models', init, options); -// Copilot serves decision models at the path each one advertises in -// `supported_endpoints` — `/v1/systemone` or `/v1/decisions` — and the body is -// forwarded in that model's own dialect, unchanged. -export const copilotFetchDecision = (config: CopilotFetchConfig, path: string, init: FetchInit, options: CopilotDataPlaneFetchOptions): Promise => - copilotFetchInternal(config, path, init, options); diff --git a/packages/provider-copilot/src/index.ts b/packages/provider-copilot/src/index.ts index daeb678809..abe5ffd267 100644 --- a/packages/provider-copilot/src/index.ts +++ b/packages/provider-copilot/src/index.ts @@ -1,6 +1,7 @@ import { COPILOT_DEFAULT_FLAGS } from './defaults.ts'; import { createCopilotProvider } from './provider.ts'; import type { ProviderModule } from '@floway-dev/provider'; +export { createCopilotPipelines } from './pipelines.ts'; export const copilotProviderModule: ProviderModule = { create: createCopilotProvider, diff --git a/packages/provider-copilot/src/operation-model.ts b/packages/provider-copilot/src/operation-model.ts new file mode 100644 index 0000000000..e7320af01b --- /dev/null +++ b/packages/provider-copilot/src/operation-model.ts @@ -0,0 +1,106 @@ +import { resolveCopilotRawModel, type ModelSelectionHints } from './model-selection.ts'; +import type { CopilotRawModel } from './types.ts'; +import type { DecisionProtocol, DecisionTarget, ModelEndpointKey } from '@floway-dev/protocols/common'; +import type { ProviderModel } from '@floway-dev/provider'; + +interface CopilotProviderData { readonly rawModels: readonly CopilotRawModel[] } + +// Copilot's `/models` reports each model's served endpoints as public paths; map +// one onto our structured endpoint key. Both `/x` and `/v1/x` spellings appear. +// Copilot is the only upstream whose catalog speaks paths — operator config and +// our own constants are structured — so this lives here, not in a shared helper. +const copilotPathToModelEndpoint = (path: string): ModelEndpointKey | undefined => { + switch (path) { + case '/chat/completions': + case '/v1/chat/completions': + return 'openaiChatCompletions'; + case '/responses': + case '/v1/responses': + return 'openaiResponses'; + case '/v1/messages': + case '/messages': + return 'anthropicMessages'; + case '/embeddings': + case '/v1/embeddings': + return 'openaiEmbeddings'; + case '/images/generations': + case '/v1/images/generations': + return 'openaiImagesGenerations'; + case '/images/edits': + case '/v1/images/edits': + return 'openaiImagesEdits'; + case '/systemone': + case '/v1/systemone': + case '/decisions': + case '/v1/decisions': + return 'decision'; + default: + return undefined; + } +}; + +// Copilot names the dialect each decision model serves through the path it +// advertises: `/v1/systemone` speaks System One and `/v1/decisions` speaks +// OpenAI Decisions. The two are exclusive per model — calling the other one +// answers 400 `no_available_model_endpoints` — so the advertised path is the +// only authority for the target dialect. +const COPILOT_DECISION_PATH_PROTOCOL: Readonly> = { + '/systemone': 'typesafe-systemone', + '/v1/systemone': 'typesafe-systemone', + '/decisions': 'openai-decisions', + '/v1/decisions': 'openai-decisions', +}; + +export const copilotDecisionTarget = (variants: readonly CopilotRawModel[]): DecisionTarget => { + const advertised = variants.flatMap(variant => variant.supported_endpoints ?? []); + const protocols = [...new Set(advertised.flatMap(path => COPILOT_DECISION_PATH_PROTOCOL[path] ?? []))]; + if (protocols.length !== 1) { + throw new Error( + `Copilot decision model advertises ${protocols.length === 0 ? 'no decision path' : `multiple decision dialects (${protocols.join(', ')})`} in supported_endpoints ${JSON.stringify(advertised)}`, + ); + } + return { protocol: protocols[0] }; +}; + +export const rawModelSupportsEndpoint = (model: CopilotRawModel, endpoint: ModelEndpointKey): boolean => { + if ((model.supported_endpoints ?? []).some(path => copilotPathToModelEndpoint(path) === endpoint)) return true; + // Copilot's Anthropic-family entries have historically under-reported their + // native Anthropic Messages path, so treat claude-* as Anthropic-Messages-capable. + if (endpoint === 'anthropicMessages' && model.id.startsWith('claude-')) return true; + if (endpoint === 'openaiChatCompletions') { + return model.supported_endpoints === undefined && model.capabilities?.type === 'chat'; + } + if (endpoint === 'openaiEmbeddings') return model.supported_endpoints === undefined && model.capabilities?.type === 'embeddings'; + // A decision model declares `capabilities.type: "decisions"`. The advertised + // path alone cannot decide this: the same family is reached over + // `/v1/systemone` and `/v1/decisions`, and a catalog that omits + // `supported_endpoints` (or lists a path this map does not know yet) would + // otherwise fall through to the chat fallback and be routed to + // /v1/chat/completions. The declared type is the authority. + if (endpoint === 'decision') return model.capabilities?.type === 'decisions'; + return false; +}; + +export const rawModelFor = (model: Pick, endpoint: ModelEndpointKey, hints: ModelSelectionHints = {}): CopilotRawModel => { + // Copilot exposes one canonical public Claude model id per family. Raw + // variant selection is derived from request body fields and parsed Anthropic Messages + // beta intent, not from the client's original model alias string. + const rawModels = (model.providerData as CopilotProviderData).rawModels.filter(rawModel => rawModelSupportsEndpoint(rawModel, endpoint)); + if (rawModels.length === 0) { + throw new Error(`Copilot provider exposed ${endpoint} for ${model.id}, but no raw variant supports that endpoint`); + } + return resolveCopilotRawModel({ object: 'list', data: rawModels }, model.id, hints) ?? rawModels[0]; +}; + +export const copilotOpenAIEmbeddingsBody = (body: Record): Record => { + if (typeof body.input !== 'string') return body; + + // OpenAI-compatible clients may send scalar string input, but Copilot's + // upstream /embeddings endpoint currently returns 400 unless text input is + // wrapped as an array. + // References: + // https://platform.openai.com/docs/api-reference/embeddings/create + // https://github.com/ericc-ch/copilot-api/blob/0ea08febdd7e3e055b03dd298bf57e669500b5c1/src/services/copilot/create-embeddings.ts#L19-L21 + // https://github.com/BerriAI/litellm/blob/c8fb77f119ad69a80f5fde088efd3a1aa77f458b/litellm/proxy/proxy_server.py#L7826-L7839 + return { ...body, input: [body.input] }; +}; diff --git a/packages/provider-copilot/src/pipeline-facts.ts b/packages/provider-copilot/src/pipeline-facts.ts new file mode 100644 index 0000000000..69f3345311 --- /dev/null +++ b/packages/provider-copilot/src/pipeline-facts.ts @@ -0,0 +1,21 @@ +import type { HttpRequestFacts, HttpResponseFacts } from '@floway-dev/http/pipeline'; +import type { Deferred, Secret } from '@floway-dev/pipeline'; +import type { ProviderOperationPayloads, ProviderRequest } from '@floway-dev/provider'; + +/** The operations Copilot serves through pipelines. Each name is also the catalog endpoint key + * its raw model variant is selected by. */ +export type CopilotOperation = 'openaiEmbeddings' | 'openaiImagesGenerations' | 'openaiImagesEdits' | 'decision'; + +export type CopilotOperationRequest = ProviderRequest; +export interface CopilotSession { + readonly token: Secret; + readonly expiresAt: number; + readonly baseUrl: string; +} +export type CopilotRequest = CopilotOperationRequest & { + 'request.copilot.session': CopilotSession; +}; +export interface CopilotHttpFacts extends HttpRequestFacts { + 'request.copilot.modelKey': string; +} +export type QuotaResponse = HttpResponseFacts & { 'response.copilot.quota': Deferred }; diff --git a/packages/provider-copilot/src/pipelines.ts b/packages/provider-copilot/src/pipelines.ts new file mode 100644 index 0000000000..e47695dfd3 --- /dev/null +++ b/packages/provider-copilot/src/pipelines.ts @@ -0,0 +1,19 @@ +import type { CopilotAuth } from './auth.ts'; +import type { CopilotOperation, CopilotOperationRequest } from './pipeline-facts.ts'; +import { authenticateCopilot } from './stages/authenticate.ts'; +import { observeCopilotQuota } from './stages/observe-quota.ts'; +import { prepareCopilotRequest } from './stages/prepare-request.ts'; +import { http } from '@floway-dev/http/pipeline'; +import { compose } from '@floway-dev/pipeline'; +import type { ProviderPipelines, ProviderResponse } from '@floway-dev/provider'; + +export const createCopilotPipelines = (auth: CopilotAuth): ProviderPipelines => { + const pipeline = (operation: O) => + compose, ProviderResponse>(`copilot.${operation}`, [authenticateCopilot(auth, operation), prepareCopilotRequest(operation), observeCopilotQuota(auth.id), http]); + return { + openaiEmbeddings: pipeline('openaiEmbeddings'), + openaiImagesGenerations: pipeline('openaiImagesGenerations'), + openaiImagesEdits: pipeline('openaiImagesEdits'), + decision: pipeline('decision'), + }; +}; diff --git a/packages/provider-copilot/src/provider.ts b/packages/provider-copilot/src/provider.ts index 28ca4fac03..e77c273476 100644 --- a/packages/provider-copilot/src/provider.ts +++ b/packages/provider-copilot/src/provider.ts @@ -3,7 +3,7 @@ import { COMPACTION_TRIGGER, compactionResponse } from './compaction.ts'; import { assertCopilotUpstreamRecord } from './config.ts'; import { COPILOT_DEFAULT_FLAGS, defaultFlagsForCopilotModel } from './defaults.ts'; import { fetchCopilotModels } from './fetch-models.ts'; -import { copilotFetchOpenAIChatCompletions, copilotFetchOpenAIEmbeddings, copilotFetchAnthropicMessages, copilotFetchAnthropicMessagesCountTokens, copilotFetchOpenAIImagesEdits, copilotFetchOpenAIImagesGenerations, copilotFetchOpenAIResponses, copilotFetchDecision, type CopilotDataPlaneFetchOptions } from './fetch.ts'; +import { copilotFetchOpenAIChatCompletions, copilotFetchOpenAIEmbeddings, copilotFetchAnthropicMessages, copilotFetchAnthropicMessagesCountTokens, copilotFetchOpenAIImagesEdits, copilotFetchOpenAIImagesGenerations, copilotFetchOpenAIResponses, type CopilotDataPlaneFetchOptions } from './fetch.ts'; import { COPILOT_ANTHROPIC_MESSAGES_BOUNDARY, COPILOT_ANTHROPIC_MESSAGES_COUNT_TOKENS_BOUNDARY } from './interceptors/anthropic-messages/index.ts'; import type { AnthropicMessagesBoundaryCtx } from './interceptors/anthropic-messages/types.ts'; import { COPILOT_OPENAI_CHAT_COMPLETIONS_BOUNDARY } from './interceptors/openai-chat-completions/index.ts'; @@ -12,16 +12,17 @@ import { COPILOT_OPENAI_RESPONSES_BOUNDARY } from './interceptors/openai-respons import type { OpenAIResponsesBoundaryCtx } from './interceptors/openai-responses/types.ts'; import { emptyKnownModels, mergeKnownModels, projectKnownModels } from './known-models.ts'; import { mergeCopilotVariants } from './merge-variants.ts'; -import { CONTEXT_1M_BETA, copilotModelSupportsFastVariant, type ModelSelectionHints, resolveCopilotRawModel } from './model-selection.ts'; +import { CONTEXT_1M_BETA, copilotModelSupportsFastVariant } from './model-selection.ts'; import { copilotVariantIndex } from './model-variants.ts'; +import { copilotDecisionTarget, copilotOpenAIEmbeddingsBody, rawModelFor, rawModelSupportsEndpoint } from './operation-model.ts'; +import { createCopilotPipelines } from './pipelines.ts'; import { pricingForCopilotPublicModelId } from './pricing.ts'; import { runCopilotScheduledTask } from './scheduled.ts'; import { readCopilotUpstreamState, type CopilotUpstreamState } from './state.ts'; import type { CopilotRawModel } from './types.ts'; import { runInterceptors } from '@floway-dev/interceptor'; import { parseAnthropicMessagesStream, type AnthropicMessagesPayload, type AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; -import { type DecisionProtocol, type DecisionTarget, type ModelEndpointKey, type ModelEndpoints, type ProtocolFrame, isFastServiceTier, kindForEndpoints } from '@floway-dev/protocols/common'; -import { DEFAULT_DECISION_PATHS, serializeDecisionRequest, type CanonicalDecisionRequest } from '@floway-dev/protocols/decision'; +import { type ModelEndpoints, type ProtocolFrame, isFastServiceTier, kindForEndpoints } from '@floway-dev/protocols/common'; import { parseOpenAIChatCompletionsStream, type OpenAIChatCompletionsPayload, type OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; import { parseOpenAIResponsesStream, type CanonicalOpenAIResponsesPayload, type OpenAIResponsesResultEx } from '@floway-dev/protocols/openai-responses'; import { eventResult, getProviderRepo, headersForAnthropicMessagesCall, jsonRequestBody, serializeOpenAIImagesEditsRequest, readUpstreamApiError, streamingProviderCall, apiErrorToResponse, resolveEffectiveFlags, type ExecuteResult, type FetchInit, type FlagOverrides, type HttpHeaderLines, type ProviderInstance, type Provider, type ProviderCallResult, type ProviderModel, type ProviderOpenAIResponsesResult, type ProviderStreamResult, type TelemetryModelIdentity, type UpstreamCallOptions, type UpstreamRecord } from '@floway-dev/provider'; @@ -55,82 +56,6 @@ const copilotRawToProviderModel = (model: CopilotRawModel): Omit { - switch (path) { - case '/chat/completions': - case '/v1/chat/completions': - return 'openaiChatCompletions'; - case '/responses': - case '/v1/responses': - return 'openaiResponses'; - case '/v1/messages': - case '/messages': - return 'anthropicMessages'; - case '/embeddings': - case '/v1/embeddings': - return 'openaiEmbeddings'; - case '/images/generations': - case '/v1/images/generations': - return 'openaiImagesGenerations'; - case '/images/edits': - case '/v1/images/edits': - return 'openaiImagesEdits'; - case '/systemone': - case '/v1/systemone': - case '/decisions': - case '/v1/decisions': - return 'decision'; - default: - return undefined; - } -}; - -// Copilot names the dialect each decision model serves through the path it -// advertises: `/v1/systemone` speaks System One and `/v1/decisions` speaks -// OpenAI Decisions. The two are exclusive per model — calling the other one -// answers 400 `no_available_model_endpoints` — so the advertised path is the -// only authority for the target dialect. -const COPILOT_DECISION_PATH_PROTOCOL: Readonly> = { - '/systemone': 'typesafe-systemone', - '/v1/systemone': 'typesafe-systemone', - '/decisions': 'openai-decisions', - '/v1/decisions': 'openai-decisions', -}; - -const copilotDecisionTarget = (variants: readonly CopilotRawModel[]): DecisionTarget => { - const advertised = variants.flatMap(variant => variant.supported_endpoints ?? []); - const protocols = [...new Set(advertised.flatMap(path => COPILOT_DECISION_PATH_PROTOCOL[path] ?? []))]; - if (protocols.length !== 1) { - throw new Error( - `Copilot decision model advertises ${protocols.length === 0 ? 'no decision path' : `multiple decision dialects (${protocols.join(', ')})`} in supported_endpoints ${JSON.stringify(advertised)}`, - ); - } - return { protocol: protocols[0] }; -}; - -const rawModelSupportsEndpoint = (model: CopilotRawModel, endpoint: ModelEndpointKey): boolean => { - if ((model.supported_endpoints ?? []).some(path => copilotPathToModelEndpoint(path) === endpoint)) return true; - // Copilot's Anthropic-family entries have historically under-reported their - // native Anthropic Messages path, so treat claude-* as Anthropic-Messages-capable. - if (endpoint === 'anthropicMessages' && model.id.startsWith('claude-')) return true; - if (endpoint === 'openaiChatCompletions') { - return model.supported_endpoints === undefined && model.capabilities?.type === 'chat'; - } - if (endpoint === 'openaiEmbeddings') return model.supported_endpoints === undefined && model.capabilities?.type === 'embeddings'; - // A decision model declares `capabilities.type: "decisions"`. The advertised - // path alone cannot decide this: the same family is reached over - // `/v1/systemone` and `/v1/decisions`, and a catalog that omits - // `supported_endpoints` (or lists a path this map does not know yet) would - // otherwise fall through to the chat fallback and be routed to - // /v1/chat/completions. The declared type is the authority. - if (endpoint === 'decision') return model.capabilities?.type === 'decisions'; - return false; -}; - const copilotModelEndpoints = (rawModels: readonly CopilotRawModel[]): ModelEndpoints => { if (rawModels.some(model => rawModelSupportsEndpoint(model, 'openaiResponses'))) { return { openaiResponses: {} }; @@ -184,30 +109,6 @@ const anthropicMessagesBoundaryContext = ( const rejectUnsupported = (capability: string) => (): Promise => Promise.reject(new Error(`Copilot provider does not implement ${capability}`)); -const rawModelFor = (model: ProviderModel, endpoint: ModelEndpointKey, hints: ModelSelectionHints = {}): CopilotRawModel => { - // Copilot exposes one canonical public Claude model id per family. Raw - // variant selection is derived from request body fields and parsed Anthropic Messages - // beta intent, not from the client's original model alias string. - const rawModels = (model.providerData as CopilotProviderData).rawModels.filter(rawModel => rawModelSupportsEndpoint(rawModel, endpoint)); - if (rawModels.length === 0) { - throw new Error(`Copilot provider exposed ${endpoint} for ${model.id}, but no raw variant supports that endpoint`); - } - return resolveCopilotRawModel({ object: 'list', data: rawModels }, model.id, hints) ?? rawModels[0]; -}; - -const copilotOpenAIEmbeddingsBody = (body: Record): Record => { - if (typeof body.input !== 'string') return body; - - // OpenAI-compatible clients may send scalar string input, but Copilot's - // upstream /embeddings endpoint currently returns 400 unless text input is - // wrapped as an array. - // References: - // https://platform.openai.com/docs/api-reference/embeddings/create - // https://github.com/ericc-ch/copilot-api/blob/0ea08febdd7e3e055b03dd298bf57e669500b5c1/src/services/copilot/create-embeddings.ts#L19-L21 - // https://github.com/BerriAI/litellm/blob/c8fb77f119ad69a80f5fde088efd3a1aa77f458b/litellm/proxy/proxy_server.py#L7826-L7839 - return { ...body, input: [body.input] }; -}; - const finalizeCopilotModels = ( rawModels: CopilotRawModel[], upstreamOverrides: FlagOverrides, @@ -557,19 +458,6 @@ export const createCopilotProvider = (record: UpstreamRecord): Provider => { }, callOpenAIAudioTranscriptions: rejectUnsupported('callOpenAIAudioTranscriptions'), callRerank: rejectUnsupported('callRerank'), - callDecision: async (model: ProviderModel, request: CanonicalDecisionRequest, signal: AbortSignal | undefined, opts: UpstreamCallOptions) => { - const target = model.decisionTarget; - if (target === undefined) throw new Error(`Decision model ${model.id} has no outbound target`); - const rawModel = rawModelFor(model, 'decision'); - const body = serializeDecisionRequest(target.protocol, rawModel.id, request); - const response = await copilotFetchDecision( - upstreamConfig, - target.path ?? DEFAULT_DECISION_PATHS[target.protocol], - { method: 'POST', body: jsonRequestBody(body), signal }, - { extraHeaders: [...opts.headers], fetcher: opts.fetcher, wrapUpstreamCall: opts.wrapUpstreamCall }, - ); - return { response, modelKey: rawModel.id, target }; - }, }; return { @@ -580,6 +468,7 @@ export const createCopilotProvider = (record: UpstreamRecord): Provider => { disabledPublicModelIds: copilot.disabledPublicModelIds, modelPrefix: copilot.modelPrefix, modelsCache: copilot.modelsCache, + pipelines: createCopilotPipelines(upstreamConfig), instance, }; }; diff --git a/packages/provider-copilot/src/stages/authenticate.ts b/packages/provider-copilot/src/stages/authenticate.ts new file mode 100644 index 0000000000..13a7c47037 --- /dev/null +++ b/packages/provider-copilot/src/stages/authenticate.ts @@ -0,0 +1,41 @@ +import { getCopilotToken, isCopilotTokenFetchError, type CopilotAuth } from '../auth.ts'; +import { rawModelFor } from '../operation-model.ts'; +import type { CopilotOperation, CopilotOperationRequest, CopilotRequest } from '../pipeline-facts.ts'; +import { takeHttpResponse } from '@floway-dev/http/pipeline'; +import { defineStage, move, secret, type Handed } from '@floway-dev/pipeline'; +import type { ProviderResponse, ProviderServices } from '@floway-dev/provider'; + +export const authenticateCopilot = (auth: CopilotAuth, operation: O) => { + const authenticate = defineStage, CopilotRequest, ProviderResponse, ProviderResponse, ProviderResponse, ProviderServices>({ + name: 'authenticateCopilot', + through: { + request: { + needs: ['request.provider.model', 'request.http.callId'], + consumes: [], + provides: ['request.copilot.session'], + }, + response: { needs: ['response.http.exchange', 'response.provider.modelKey', 'response.provider.called'], consumes: [], provides: [] }, + }, + return: { provides: ['response.http.exchange', 'response.http.body', 'response.provider.modelKey', 'response.provider.called', 'response.provider.previousCalls'] }, + execute: async (facts, next, use) => { + const call = use.httpCall(facts['request.http.callId']); + let entry; + try { + entry = await getCopilotToken(auth.id, auth.githubHost, auth.githubToken, call.fetcher, call.signal); + } catch (error) { + if (!isCopilotTokenFetchError(error)) throw error; + const exchange = takeHttpResponse(new Response(error.body, { status: error.status, headers: error.headers })); + return move({ + ...facts, + 'response.http.exchange': exchange, + 'response.http.body': exchange.body, + 'response.provider.modelKey': rawModelFor(facts['request.provider.model'], operation).id, + 'response.provider.called': false, + 'response.provider.previousCalls': [], + }); + } + return await next(move({ ...facts, 'request.copilot.session': { ...entry, token: secret(entry.token) } })) as Handed; + }, + }); + return authenticate; +}; diff --git a/packages/provider-copilot/src/stages/observe-quota.ts b/packages/provider-copilot/src/stages/observe-quota.ts new file mode 100644 index 0000000000..717ce62af5 --- /dev/null +++ b/packages/provider-copilot/src/stages/observe-quota.ts @@ -0,0 +1,26 @@ +import type { QuotaResponse } from '../pipeline-facts.ts'; +import { parseCopilotQuotaHeaders, putCopilotQuota } from '../quota.ts'; +import type { HttpRequestFacts, HttpResponseFacts } from '@floway-dev/http/pipeline'; +import { defer, defineStage, move } from '@floway-dev/pipeline'; +import type { ProviderServices } from '@floway-dev/provider'; + +export const observeCopilotQuota = (upstreamId: string) => { + const observeQuota = defineStage, Pick, HttpResponseFacts, QuotaResponse, ProviderServices>({ + name: 'observeCopilotQuota', + through: { + request: { needs: ['request.http.callId'], consumes: [], provides: [] }, + response: { needs: ['response.http.exchange'], consumes: [], provides: ['response.copilot.quota'] }, + }, + execute: async (facts, next) => { + const back = await next(move({ ...facts })); + const exchange = back['response.http.exchange']; + const pending: Promise[] = []; + if (exchange.type === 'response') { + const snapshot = parseCopilotQuotaHeaders(new Headers(exchange.headers.map(([name, value]): [string, string] => [name, value])), new Date()); + if (snapshot !== null) pending.push(putCopilotQuota(upstreamId, snapshot)); + } + return move({ ...back, 'response.copilot.quota': defer(Promise.all(pending)) }); + }, + }); + return observeQuota; +}; diff --git a/packages/provider-copilot/src/stages/prepare-request.ts b/packages/provider-copilot/src/stages/prepare-request.ts new file mode 100644 index 0000000000..b58b1afd3a --- /dev/null +++ b/packages/provider-copilot/src/stages/prepare-request.ts @@ -0,0 +1,82 @@ +import { copilotRequestHeaders } from '../auth.ts'; +import { copilotOpenAIEmbeddingsBody, rawModelFor } from '../operation-model.ts'; +import type { CopilotHttpFacts, CopilotOperation, CopilotRequest } from '../pipeline-facts.ts'; +import type { HttpResponseFacts } from '@floway-dev/http/pipeline'; +import type { HttpBody, HttpBodyEncoding } from '@floway-dev/http/request-content'; +import { defineStage, isSecret, move, secret, type Secret } from '@floway-dev/pipeline'; +import { DEFAULT_DECISION_PATHS, serializeDecisionRequest } from '@floway-dev/protocols/decision'; +import { prepareOpenAIImagesEditsBody, withHttpContentType, type ProviderOperationPayloads, type ProviderResponse, type ProviderServices } from '@floway-dev/provider'; + +interface PreparedCopilotRequest { + readonly path: string; + readonly body: HttpBody; + readonly encoding: HttpBodyEncoding; +} + +// The image endpoints advertise `/v1/...` paths and only answer there: the +// unprefixed `/images/edits` the other data-plane surfaces use returns a bare +// `404 page not found`. `/v1/images/generations` accepts JSON only, while +// `/v1/images/edits` accepts JSON references and multipart uploads; it refuses +// a `reference` carrying a remote URL with `400 external image URLs are not +// supported`, which reaches the client as Copilot's own answer. +// Decision models answer at the path each one advertises in +// `supported_endpoints`, in that model's own dialect. +const prepareBody = async ( + operation: O, + model: CopilotRequest['request.provider.model'], + payload: ProviderOperationPayloads[O], + modelKey: string, +): Promise => { + switch (operation) { + case 'openaiEmbeddings': + return { path: '/embeddings', body: { ...copilotOpenAIEmbeddingsBody(payload as ProviderOperationPayloads['openaiEmbeddings']), model: modelKey }, encoding: 'json' }; + case 'openaiImagesGenerations': + return { path: '/v1/images/generations', body: { ...(payload as ProviderOperationPayloads['openaiImagesGenerations']), model: modelKey }, encoding: 'json' }; + case 'openaiImagesEdits': + return { path: '/v1/images/edits', ...await prepareOpenAIImagesEditsBody(payload as ProviderOperationPayloads['openaiImagesEdits'], modelKey) }; + case 'decision': { + const target = model.decisionTarget; + if (target === undefined) throw new Error(`Decision model ${model.id} has no outbound target`); + return { + path: target.path ?? DEFAULT_DECISION_PATHS[target.protocol], + body: serializeDecisionRequest(target.protocol, modelKey, payload as ProviderOperationPayloads['decision']), + encoding: 'json', + }; + } + default: + throw new Error(`Copilot has no pipeline for ${String(operation)}`); + } +}; + +export const prepareCopilotRequest = (operation: O) => { + const prepare = defineStage, CopilotHttpFacts, HttpResponseFacts, ProviderResponse, ProviderServices>({ + name: `prepareCopilot${operation.replace(/^openai/, 'OpenAI').replace(/^decision/, 'Decision')}`, + through: { + request: { + needs: ['request.provider.model', 'request.provider.payload', 'request.http.headers', 'request.http.callId', 'request.copilot.session'], + consumes: ['request.provider.model', 'request.provider.payload', 'request.copilot.session', 'request.http.headers'], + provides: ['request.http.url', 'request.http.method', 'request.http.headers', 'request.http.body', 'request.http.encoding', 'request.copilot.modelKey'], + }, + response: { needs: ['response.http.exchange'], consumes: [], provides: ['response.provider.modelKey', 'response.provider.called', 'response.provider.previousCalls'] }, + }, + execute: async (facts, next) => { + const { 'request.provider.model': model, 'request.provider.payload': payload, 'request.copilot.session': session, ...rest } = facts; + const modelKey = rawModelFor(model, operation).id; + const prepared = await prepareBody(operation, model, payload, modelKey); + const admitted = facts['request.http.headers'].map(([name, value]): [string, string] => [name, isSecret(value) ? value.reveal() : value]); + const identity = [...copilotRequestHeaders({ ...session, token: session.token.reveal() }, undefined, admitted)] + .map(([name, value]): [string, string | Secret] => [name, name === 'authorization' ? secret(value) : value]); + const back = await next(move({ + ...rest, + 'request.copilot.modelKey': modelKey, + 'request.http.url': `${session.baseUrl}${prepared.path}`, + 'request.http.method': 'POST', + 'request.http.headers': withHttpContentType(identity, prepared.body, prepared.encoding), + 'request.http.body': prepared.body, + 'request.http.encoding': prepared.encoding, + })); + return move({ ...back, 'response.provider.modelKey': modelKey, 'response.provider.called': back['response.http.exchange'].type === 'response', 'response.provider.previousCalls': [] }); + }, + }); + return prepare; +}; diff --git a/packages/provider-custom/__tests__/fetch_test.ts b/packages/provider-custom/__tests__/fetch_test.ts index f773b8c3c2..4a74c9fdc9 100644 --- a/packages/provider-custom/__tests__/fetch_test.ts +++ b/packages/provider-custom/__tests__/fetch_test.ts @@ -15,7 +15,7 @@ import { import { createCustomProvider } from '../src/provider.ts'; import type { UpstreamRecord } from '@floway-dev/provider'; import { directFetcher, identityWrapUpstreamCall } from '@floway-dev/provider'; -import { assertEquals, assertExists, jsonResponse, noopUpstreamCallOptions, withMockedFetch } from '@floway-dev/test-utils'; +import { callProviderPipeline, assertEquals, assertExists, jsonResponse, noopUpstreamCallOptions, withMockedFetch } from '@floway-dev/test-utils'; const baseRecord: UpstreamRecord = { id: 'up_test', @@ -287,11 +287,11 @@ test('Custom provider callOpenAIImagesEdits forwards multipart body with model f async () => { const provider = createCustomProvider(record); const [model] = await provider.instance.getProvidedModels(directFetcher); - const result = await provider.instance.callOpenAIImagesEdits(model, { + const result = await callProviderPipeline(provider, 'openaiImagesEdits', model, { parameters: { prompt: 'add a kite' }, images: [{ type: 'upload', - file: new File([new Uint8Array([1, 2, 3])], 'photo.png', { type: 'image/png' }), + file: { bytes: new Uint8Array([1, 2, 3]), name: 'photo.png', type: 'image/png' }, }], }, undefined, noopUpstreamCallOptions()); assertEquals(result.modelKey, 'gpt-image-2'); @@ -327,9 +327,9 @@ test('Custom provider callOpenAIAudioTranscriptions preserves multipart entries async () => { const provider = createCustomProvider(record); const [model] = await provider.instance.getProvidedModels(directFetcher); - const result = await provider.instance.callOpenAIAudioTranscriptions(model, { + const result = await callProviderPipeline(provider, 'openaiAudioTranscriptions', model, { entries: [ - { name: 'file', value: new File([new Uint8Array([7, 8])], 'voice.ogg', { type: 'audio/ogg' }) }, + { name: 'file', value: { bytes: new Uint8Array([7, 8]), name: 'voice.ogg', type: 'audio/ogg' } }, { name: 'model', value: 'public-model' }, { name: 'language', value: 'en' }, ], diff --git a/packages/provider-custom/__tests__/ingress-header-rules_test.ts b/packages/provider-custom/__tests__/ingress-header-rules_test.ts index b3cfe7ef0b..a98d2995b3 100644 --- a/packages/provider-custom/__tests__/ingress-header-rules_test.ts +++ b/packages/provider-custom/__tests__/ingress-header-rules_test.ts @@ -4,7 +4,7 @@ import type { CustomIngressHeaderRule } from '../src/config.ts'; import { createCustomProvider } from '../src/provider.ts'; import { parseRerankRequest } from '@floway-dev/protocols/rerank'; import { directFetcher, type Fetcher, type UpstreamModelConfig, type UpstreamRecord } from '@floway-dev/provider'; -import { assertEquals, assertExists, jsonResponse, noopAnthropicMessagesUpstreamCallOptions, noopUpstreamCallOptions, sseResponse, withMockedFetch } from '@floway-dev/test-utils'; +import { callProviderPipeline, assertEquals, assertExists, jsonResponse, noopAnthropicMessagesUpstreamCallOptions, noopUpstreamCallOptions, sseResponse, withMockedFetch } from '@floway-dev/test-utils'; const HEADER = 'x-route'; @@ -196,28 +196,27 @@ test('every endpoint resolves the same rules', async () => { const messagesOpts = () => noopAnthropicMessagesUpstreamCallOptions({ headers: headers() }); const messagesBody = { max_tokens: 10, messages: [{ role: 'user' as const, content: 'hi' }] }; - await provider.instance.callAlphaSearch(model, { query: 'hi' }, undefined, opts()); + await callProviderPipeline(provider, 'alphaSearch', model, { query: 'hi' }, undefined, opts()); await provider.instance.callOpenAIChatCompletions(model, { messages: [] }, undefined, opts()); - await provider.instance.callOpenAICompletions(model, { prompt: 'hi' }, undefined, opts()); + await callProviderPipeline(provider, 'openaiCompletions', model, { prompt: 'hi' }, undefined, opts()); await provider.instance.callOpenAIResponses(model, { input: [] }, 'generate', undefined, opts()); await provider.instance.callOpenAIResponses(model, { input: [] }, 'compact', undefined, opts()); await provider.instance.callAnthropicMessages(model, messagesBody, undefined, messagesOpts()); await provider.instance.callAnthropicMessagesCountTokens(model, messagesBody, undefined, messagesOpts()); - await provider.instance.callOpenAIEmbeddings(model, { input: 'hi' }, undefined, opts()); - await provider.instance.callOpenAIImagesGenerations(model, { prompt: 'hi' }, undefined, opts()); - await provider.instance.callOpenAIImagesEdits(model, { + await callProviderPipeline(provider, 'openaiEmbeddings', model, { input: 'hi' }, undefined, opts()); + await callProviderPipeline(provider, 'openaiImagesGenerations', model, { prompt: 'hi' }, undefined, opts()); + await callProviderPipeline(provider, 'openaiImagesEdits', model, { parameters: { prompt: 'hi' }, - images: [{ type: 'upload', file: new File([new Uint8Array([1])], 'photo.png', { type: 'image/png' }) }], + images: [{ type: 'upload', file: { bytes: new Uint8Array([1]), name: 'photo.png', type: 'image/png' } }], }, undefined, opts()); - await provider.instance.callOpenAIAudioTranscriptions(model, { - entries: [{ name: 'file', value: new File([new Uint8Array([1])], 'voice.ogg', { type: 'audio/ogg' }) }], + await callProviderPipeline(provider, 'openaiAudioTranscriptions', model, { + entries: [{ name: 'file', value: { bytes: new Uint8Array([1]), name: 'voice.ogg', type: 'audio/ogg' } }], }, undefined, opts()); - await provider.instance.callRerank( + await callProviderPipeline(provider, 'rerank', rerankModel, parseRerankRequest('cohere-v1', { model: 'reranker', query: 'query', documents: ['one'] }).request, undefined, - opts(), - ); + opts()); }, ); diff --git a/packages/provider-custom/__tests__/provider_test.ts b/packages/provider-custom/__tests__/provider_test.ts index 20cf818126..287cfcbf82 100644 --- a/packages/provider-custom/__tests__/provider_test.ts +++ b/packages/provider-custom/__tests__/provider_test.ts @@ -5,7 +5,7 @@ import type { ModelPricing } from '@floway-dev/protocols/common'; import { parseRerankRequest } from '@floway-dev/protocols/rerank'; import type { UpstreamModelConfig, UpstreamRecord } from '@floway-dev/provider'; import { directFetcher } from '@floway-dev/provider'; -import { assertEquals, assertExists, assertRejects, jsonResponse, noopAnthropicMessagesUpstreamCallOptions, noopUpstreamCallOptions, sseResponse, withMockedFetch } from '@floway-dev/test-utils'; +import { callProviderPipeline, assertEquals, assertExists, assertRejects, jsonResponse, noopAnthropicMessagesUpstreamCallOptions, noopUpstreamCallOptions, sseResponse, withMockedFetch } from '@floway-dev/test-utils'; interface BuildOptions { ingressHeadersRules?: { key: string; value: string | null }[]; @@ -341,12 +341,11 @@ test('callRerank uses the model target protocol, raw model id, and canonical pat return jsonResponse({ results: [] }); }, async () => { - const result = await instance.instance.callRerank( + const result = await callProviderPipeline(instance, 'rerank', model, parseRerankRequest('cohere-v1', { model: 'public-reranker', query: 'query', documents: ['one'], top_n: 1 }).request, undefined, - noopUpstreamCallOptions(), - ); + noopUpstreamCallOptions()); assertEquals(result.target, { protocol: 'cohere-v2' }); assertEquals(result.modelKey, 'raw-reranker'); }, @@ -374,19 +373,19 @@ test('callRerank honors the per-model path without adding an upstream path overr return jsonResponse({ output: { results: [] } }); }, async () => { - await instance.instance.callRerank( + await callProviderPipeline(instance, 'rerank', model, parseRerankRequest('jina-v1', { model: 'raw-reranker', query: 'query', documents: ['one'] }).request, undefined, - noopUpstreamCallOptions(), - ); + noopUpstreamCallOptions()); }, ); assertEquals(requestUrl, 'https://custom.example.com/workspace/rerank'); }); test('Custom provider forces stream=true for streaming endpoints and leaves count-tokens/embeddings alone', async () => { - const provider = createCustomProvider(buildCustomUpstream()).instance; + const instance = createCustomProvider(buildCustomUpstream()); + const provider = instance.instance; const bodies: Record> = {}; const betas: Record = {}; @@ -415,7 +414,7 @@ test('Custom provider forces stream=true for streaming endpoints and leaves coun await provider.callOpenAIResponses(model, { input: [] }, 'generate', undefined, opts); await provider.callAnthropicMessages(model, { max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] }, undefined, anthropicMessagesOpts); await provider.callAnthropicMessagesCountTokens(model, { max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] }, undefined, anthropicMessagesOpts); - await provider.callOpenAIEmbeddings(model, { input: 'hi' }, undefined, opts); + await callProviderPipeline(instance, 'openaiEmbeddings', model, { input: 'hi' }, undefined, opts); }, ); @@ -435,7 +434,8 @@ test('Custom provider uses configured endpoints regardless of per-model hints in await withMockedFetch( () => jsonResponse({ object: 'list', data: [{ id: 'm-1', supported_endpoints: ['/some/random/path'] }] }), async () => { - const provider = createCustomProvider(buildCustomUpstream()).instance; + const instance = createCustomProvider(buildCustomUpstream()); + const provider = instance.instance; const [model] = await provider.getProvidedModels(directFetcher); assertEquals(model.endpoints, { openaiChatCompletions: {} }); assertEquals(model.kind, 'chat'); @@ -494,7 +494,7 @@ test('Custom provider callOpenAIImagesGenerations posts JSON with model re-injec async () => { const provider = createCustomProvider(buildCustomUpstream()); const [model] = await provider.instance.getProvidedModels(directFetcher); - const result = await provider.instance.callOpenAIImagesGenerations(model, { prompt: 'hi' }, undefined, noopUpstreamCallOptions()); + const result = await callProviderPipeline(provider, 'openaiImagesGenerations', model, { prompt: 'hi' }, undefined, noopUpstreamCallOptions()); assertEquals(result.modelKey, 'gpt-image-2'); assertEquals(result.response.status, 200); }, @@ -519,12 +519,11 @@ test('Custom provider callAlphaSearch posts JSON to /v1/alpha/search with the up async () => { const provider = createCustomProvider(buildCustomUpstream()); const [model] = await provider.instance.getProvidedModels(directFetcher); - const result = await provider.instance.callAlphaSearch( + const result = await callProviderPipeline(provider, 'alphaSearch', model, { id: 'search-session', commands: { search_query: [{ q: 'Floway' }] } }, undefined, - noopUpstreamCallOptions(), - ); + noopUpstreamCallOptions()); assertEquals(result.response.status, 200); assertEquals(result.modelKey, 'gpt-search'); }, diff --git a/packages/provider-custom/package.json b/packages/provider-custom/package.json index 681984825c..2999936f61 100644 --- a/packages/provider-custom/package.json +++ b/packages/provider-custom/package.json @@ -12,6 +12,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@floway-dev/http": "workspace:*", + "@floway-dev/pipeline": "workspace:*", "@floway-dev/protocols": "workspace:*", "@floway-dev/provider": "workspace:*" }, diff --git a/packages/provider-custom/src/fetch.ts b/packages/provider-custom/src/fetch.ts index 74af4184bb..5df603f6f9 100644 --- a/packages/provider-custom/src/fetch.ts +++ b/packages/provider-custom/src/fetch.ts @@ -59,9 +59,6 @@ const customFetchInternal = async ( export const customFetchRerank = (config: CustomUpstreamConfig, path: string, init: FetchInit, options: UpstreamFetchOptions): Promise => customFetchInternal(config, path, init, options); -export const customFetchDecision = (config: CustomUpstreamConfig, path: string, init: FetchInit, options: UpstreamFetchOptions): Promise => - customFetchInternal(config, path, init, options); - export const customFetchOpenAIChatCompletions = (config: CustomUpstreamConfig, init: FetchInit, options: UpstreamFetchOptions): Promise => customFetchInternal(config, pathOverrideFor(config, '/chat/completions'), init, options); export const customFetchOpenAIResponses = (config: CustomUpstreamConfig, init: FetchInit, options: UpstreamFetchOptions): Promise => diff --git a/packages/provider-custom/src/index.ts b/packages/provider-custom/src/index.ts index 19f4a9c616..ddc575039f 100644 --- a/packages/provider-custom/src/index.ts +++ b/packages/provider-custom/src/index.ts @@ -10,3 +10,4 @@ export const customProviderModule: ProviderModule = { export { assertCustomUpstreamRecord, type CustomIngressHeaderRule, type CustomModelsFetch, type CustomUpstreamConfig } from './config.ts'; export { fetchCustomModels, type CustomModelsResponse, type CustomRawModel } from './fetch-models.ts'; export { projectCustomModels, projectCustomDiscoveredModels } from './provider.ts'; +export { createCustomPipelines } from './pipelines.ts'; diff --git a/packages/provider-custom/src/pipelines.ts b/packages/provider-custom/src/pipelines.ts new file mode 100644 index 0000000000..f8a00b6824 --- /dev/null +++ b/packages/provider-custom/src/pipelines.ts @@ -0,0 +1,16 @@ +import type { CustomUpstreamConfig } from './config.ts'; +import { prepareCustomRequest } from './stages/prepare-request.ts'; +import { http } from '@floway-dev/http/pipeline'; +import { compose } from '@floway-dev/pipeline'; +import type { ProviderOperationPayloads, ProviderOperationResponse, ProviderPipelines, ProviderRequest } from '@floway-dev/provider'; + +export const createCustomPipelines = (config: CustomUpstreamConfig): ProviderPipelines => ({ + alphaSearch: compose, ProviderOperationResponse<'alphaSearch'>>('custom.alphaSearch', [prepareCustomRequest(config, 'alphaSearch'), http]), + openaiCompletions: compose, ProviderOperationResponse<'openaiCompletions'>>('custom.openaiCompletions', [prepareCustomRequest(config, 'openaiCompletions'), http]), + openaiEmbeddings: compose, ProviderOperationResponse<'openaiEmbeddings'>>('custom.openaiEmbeddings', [prepareCustomRequest(config, 'openaiEmbeddings'), http]), + openaiImagesGenerations: compose, ProviderOperationResponse<'openaiImagesGenerations'>>('custom.openaiImagesGenerations', [prepareCustomRequest(config, 'openaiImagesGenerations'), http]), + openaiImagesEdits: compose, ProviderOperationResponse<'openaiImagesEdits'>>('custom.openaiImagesEdits', [prepareCustomRequest(config, 'openaiImagesEdits'), http]), + openaiAudioTranscriptions: compose, ProviderOperationResponse<'openaiAudioTranscriptions'>>('custom.openaiAudioTranscriptions', [prepareCustomRequest(config, 'openaiAudioTranscriptions'), http]), + rerank: compose, ProviderOperationResponse<'rerank'>>('custom.rerank', [prepareCustomRequest(config, 'rerank'), http]), + decision: compose, ProviderOperationResponse<'decision'>>('custom.decision', [prepareCustomRequest(config, 'decision'), http]), +}); diff --git a/packages/provider-custom/src/provider.ts b/packages/provider-custom/src/provider.ts index f536f0fbcf..1a0a04dd77 100644 --- a/packages/provider-custom/src/provider.ts +++ b/packages/provider-custom/src/provider.ts @@ -1,11 +1,11 @@ import { assertCustomUpstreamRecord, type CustomUpstreamConfig } from './config.ts'; import { CUSTOM_DEFAULT_FLAGS } from './defaults.ts'; import { fetchCustomModels, type CustomModelsResponse, type CustomRawModel } from './fetch-models.ts'; -import { customFetchAlphaSearch, customFetchOpenAIAudioTranscriptions, customFetchOpenAIChatCompletions, customFetchOpenAICompletions, customFetchDecision, customFetchOpenAIEmbeddings, customFetchOpenAIImagesEdits, customFetchOpenAIImagesGenerations, customFetchAnthropicMessages, customFetchAnthropicMessagesCountTokens, customFetchRerank, customFetchOpenAIResponses, customFetchOpenAIResponsesCompact } from './fetch.ts'; +import { customFetchAlphaSearch, customFetchOpenAIAudioTranscriptions, customFetchOpenAIChatCompletions, customFetchOpenAICompletions, customFetchOpenAIEmbeddings, customFetchOpenAIImagesEdits, customFetchOpenAIImagesGenerations, customFetchAnthropicMessages, customFetchAnthropicMessagesCountTokens, customFetchRerank, customFetchOpenAIResponses, customFetchOpenAIResponsesCompact } from './fetch.ts'; import { inferEndpointsFromModelId } from './infer-endpoints.ts'; +import { createCustomPipelines } from './pipelines.ts'; import { parseAnthropicMessagesStream } from '@floway-dev/protocols/anthropic-messages'; import { type ModelEndpoints, kindForEndpoints } from '@floway-dev/protocols/common'; -import { DEFAULT_DECISION_PATHS, serializeDecisionRequest, type CanonicalDecisionRequest } from '@floway-dev/protocols/decision'; import { parseOpenAIChatCompletionsStream } from '@floway-dev/protocols/openai-chat-completions'; import { parseOpenAIResponsesStream, type OpenAIResponsesCompactionResultEx, toCompactPayloadShape } from '@floway-dev/protocols/openai-responses'; import { DEFAULT_RERANK_PATHS, serializeRerankRequest } from '@floway-dev/protocols/rerank'; @@ -278,19 +278,6 @@ export const createCustomProvider = (record: UpstreamRecord): Provider => { ); return { response, modelKey: rawModelId, target }; }, - callDecision: async (model: ProviderModel, request: CanonicalDecisionRequest, signal: AbortSignal | undefined, opts: UpstreamCallOptions) => { - const target = model.decisionTarget; - if (target === undefined) throw new Error(`Decision model ${model.id} has no outbound target`); - const rawModelId = rawModelIdOf(model); - const body = serializeDecisionRequest(target.protocol, rawModelId, request); - const response = await customFetchDecision( - config, - target.path ?? DEFAULT_DECISION_PATHS[target.protocol], - { method: 'POST', body: jsonRequestBody(body), signal }, - { extraHeaders: headersForCall(opts.headers), fetcher: opts.fetcher, wrapUpstreamCall: opts.wrapUpstreamCall }, - ); - return { response, modelKey: rawModelId, target }; - }, }; return { @@ -304,6 +291,7 @@ export const createCustomProvider = (record: UpstreamRecord): Provider => { disabledPublicModelIds: record.disabledPublicModelIds, modelPrefix: record.modelPrefix, modelsCache: record.modelsCache, + pipelines: createCustomPipelines(config), instance, }; }; diff --git a/packages/provider-custom/src/stages/prepare-request.ts b/packages/provider-custom/src/stages/prepare-request.ts new file mode 100644 index 0000000000..488564cea9 --- /dev/null +++ b/packages/provider-custom/src/stages/prepare-request.ts @@ -0,0 +1,122 @@ +import type { CustomPathOverrideKey, CustomUpstreamConfig } from '../config.ts'; +import { type HttpHeaders, type HttpRequestFacts, type HttpResponseFacts } from '@floway-dev/http/pipeline'; +import { multipartBody, type HttpBody, type HttpBodyEncoding } from '@floway-dev/http/request-content'; +import { defineStage, isSecret, move, secret, type Secret } from '@floway-dev/pipeline'; +import type { RerankTarget } from '@floway-dev/protocols/common'; +import { DEFAULT_DECISION_PATHS, serializeDecisionRequest } from '@floway-dev/protocols/decision'; +import { DEFAULT_RERANK_PATHS, serializeRerankRequest } from '@floway-dev/protocols/rerank'; +import { joinBaseAndPath, mergeHttpHeaders, prepareOpenAIImagesEditsBody, replaceHttpHeader, withHttpContentType, type ProviderOperation, type ProviderOperationPayloads, type ProviderRequest, type ProviderResponse, type ProviderServices } from '@floway-dev/provider'; + +interface CustomHttpFacts extends HttpRequestFacts { + 'request.custom.modelKey': string; + 'request.custom.path': string; +} + +type CustomResponse = ProviderResponse & { 'response.provider.rerankTarget'?: RerankTarget }; + +const PATHS: Partial> = { + alphaSearch: '/alpha/search', + openaiCompletions: '/completions', + openaiEmbeddings: '/embeddings', + openaiImagesGenerations: '/images/generations', + openaiImagesEdits: '/images/edits', + openaiAudioTranscriptions: '/audio/transcriptions', +}; + +const resolvedHeaders = (config: CustomUpstreamConfig, incoming: HttpHeaders): HttpHeaders => { + const names = new Set(config.ingressHeadersRules.map(rule => rule.key.toLowerCase())); + const output: (readonly [string, string | Secret])[] = incoming.filter(([name]) => !names.has(name.toLowerCase())); + for (const rule of config.ingressHeadersRules) { + if (rule.value !== null) output.push([rule.key, rule.value]); + else { + const values = incoming.filter(([name]) => name.toLowerCase() === rule.key.toLowerCase()).map(([, value]) => value); + if (values.length === 1) output.push([rule.key, values[0]!]); + else if (values.length > 1) { + const value = values.map(value => isSecret(value) ? value.reveal() : value).join(', '); + output.push([rule.key, values.some(isSecret) ? secret(value) : value]); + } + } + } + return output; +}; + +export const prepareCustomRequest = (config: CustomUpstreamConfig, operation: O) => { + const prepare = defineStage, CustomHttpFacts, HttpResponseFacts, CustomResponse, ProviderServices>({ + name: `prepareCustom${operation.replace(/^openai/, 'OpenAI').replace(/^alpha/, 'Alpha').replace(/^rerank/, 'Rerank').replace(/^decision/, 'Decision')}`, + through: { + request: { + needs: ['request.provider.model', 'request.provider.payload', 'request.http.callId', 'request.http.headers'], + consumes: ['request.provider.model', 'request.provider.payload', 'request.http.headers'], + provides: ['request.http.url', 'request.http.method', 'request.http.headers', 'request.http.body', 'request.http.encoding', 'request.custom.modelKey', 'request.custom.path'], + }, + response: { + needs: ['response.http.exchange'], + consumes: [], + provides: ['response.provider.modelKey', 'response.provider.called', 'response.provider.previousCalls', ...(operation === 'rerank' ? ['response.provider.rerankTarget' as const] : [])], + }, + }, + execute: async (facts, next) => { + const { 'request.provider.model': model, 'request.provider.payload': payload, ...rest } = facts; + const modelKey = model.providerData as string; + let body: HttpBody; + let encoding: HttpBodyEncoding = 'json'; + let target: RerankTarget | undefined; + let path: string; + switch (operation) { + case 'rerank': + target = model.rerankTarget; + if (target === undefined) throw new Error(`Rerank model ${model.id} has no outbound target`); + path = target.path ?? DEFAULT_RERANK_PATHS[target.protocol]; + body = serializeRerankRequest(target.protocol, modelKey, payload as ProviderOperationPayloads['rerank']); + break; + case 'decision': { + const decisionTarget = model.decisionTarget; + if (decisionTarget === undefined) throw new Error(`Decision model ${model.id} has no outbound target`); + path = decisionTarget.path ?? DEFAULT_DECISION_PATHS[decisionTarget.protocol]; + body = serializeDecisionRequest(decisionTarget.protocol, modelKey, payload as ProviderOperationPayloads['decision']); + break; + } + case 'openaiImagesEdits': { + const prepared = await prepareOpenAIImagesEditsBody(payload as ProviderOperationPayloads['openaiImagesEdits'], modelKey); + body = prepared.body; + encoding = prepared.encoding; + path = config.pathOverrides?.['/images/edits'] ?? '/v1/images/edits'; + break; + } + case 'openaiAudioTranscriptions': { + const request = payload as ProviderOperationPayloads['openaiAudioTranscriptions']; + body = multipartBody(request.entries.map(entry => entry.name === 'model' ? { ...entry, value: modelKey } : entry)); + encoding = 'multipart'; + path = config.pathOverrides?.['/audio/transcriptions'] ?? '/v1/audio/transcriptions'; + break; + } + default: { + const key = PATHS[operation]!; + path = config.pathOverrides?.[key] ?? `/v1${key}`; + body = { ...payload, model: modelKey }; + } + } + let base: HttpHeaders = []; + if (config.authStyle === 'anthropic') { + // https://docs.anthropic.com/en/api/versioning + base = [['x-api-key', secret(config.apiKey)], ['anthropic-version', '2023-06-01']]; + } else if (config.authStyle === 'bearer') { + base = replaceHttpHeader(base, 'Authorization', secret(`Bearer ${config.apiKey}`)); + } + base = withHttpContentType(base, body, encoding); + const headers = mergeHttpHeaders(base, resolvedHeaders(config, facts['request.http.headers'])); + const back = await next(move({ + ...rest, + 'request.custom.modelKey': modelKey, + 'request.custom.path': path, + 'request.http.url': joinBaseAndPath(config.baseUrl, path), + 'request.http.method': 'POST', + 'request.http.headers': headers, + 'request.http.body': body, + 'request.http.encoding': encoding, + })); + return move({ ...back, 'response.provider.modelKey': modelKey, 'response.provider.called': back['response.http.exchange'].type === 'response', 'response.provider.previousCalls': [], ...(target === undefined ? {} : { 'response.provider.rerankTarget': target }) }); + }, + }); + return prepare; +}; diff --git a/packages/provider-ollama/__tests__/provider_test.ts b/packages/provider-ollama/__tests__/provider_test.ts index e7c7d9bfb2..a41d723dea 100644 --- a/packages/provider-ollama/__tests__/provider_test.ts +++ b/packages/provider-ollama/__tests__/provider_test.ts @@ -3,7 +3,7 @@ import { test } from 'vitest'; import { CREDIT_BALANCE, USAGE_TOTALS } from './usage-fixture.ts'; import { createOllamaProvider } from '../src/provider.ts'; import { initProviderRepo, type UpstreamRecord } from '@floway-dev/provider'; -import { assertEquals, assertExists, jsonResponse, noopAnthropicMessagesUpstreamCallOptions, noopUpstreamCallOptions, testFetcher, withMockedFetch } from '@floway-dev/test-utils'; +import { callProviderPipeline, assertEquals, assertExists, jsonResponse, noopAnthropicMessagesUpstreamCallOptions, noopUpstreamCallOptions, testFetcher, withMockedFetch } from '@floway-dev/test-utils'; // A cloud upstream writes its usage snapshot after the calls it serves, so the // provider needs a repo to write into wherever those calls are exercised. @@ -164,9 +164,9 @@ test('manual transcription models call Ollama without auto-advertising the endpo const models = await instance.instance.getProvidedModels(testFetcher); assertEquals(models.map(model => model.kind), ['transcription']); assertEquals(models[0]?.chat, undefined); - await instance.instance.callOpenAIAudioTranscriptions(models[0], { + await callProviderPipeline(instance, 'openaiAudioTranscriptions', models[0], { entries: [ - { name: 'file', value: new File(['audio'], 'clip.wav', { type: 'audio/wav' }) }, + { name: 'file', value: { bytes: new TextEncoder().encode('audio'), name: 'clip.wav', type: 'audio/wav' } }, { name: 'model', value: 'public-model' }, ], }, undefined, noopUpstreamCallOptions()); diff --git a/packages/provider-ollama/package.json b/packages/provider-ollama/package.json index 4c9f851de7..8e993fde35 100644 --- a/packages/provider-ollama/package.json +++ b/packages/provider-ollama/package.json @@ -11,6 +11,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@floway-dev/http": "workspace:*", + "@floway-dev/pipeline": "workspace:*", "@floway-dev/protocols": "workspace:*", "@floway-dev/provider": "workspace:*" }, diff --git a/packages/provider-ollama/src/fetch.ts b/packages/provider-ollama/src/fetch.ts index a688ca6fa1..285e634cde 100644 --- a/packages/provider-ollama/src/fetch.ts +++ b/packages/provider-ollama/src/fetch.ts @@ -27,8 +27,6 @@ const ollamaFetchInternal = async ( export const ollamaFetchOpenAIChatCompletions = (config: OllamaUpstreamConfig, init: FetchInit, options: UpstreamFetchOptions): Promise => ollamaFetchInternal(config, '/v1/chat/completions', init, options); -export const ollamaFetchDecision = (config: OllamaUpstreamConfig, path: string, init: FetchInit, options: UpstreamFetchOptions): Promise => - ollamaFetchInternal(config, path, init, options); export const ollamaFetchOpenAIResponses = (config: OllamaUpstreamConfig, init: FetchInit, options: UpstreamFetchOptions): Promise => ollamaFetchInternal(config, '/v1/responses', init, options); export const ollamaFetchOpenAIResponsesCompact = (config: OllamaUpstreamConfig, init: FetchInit, options: UpstreamFetchOptions): Promise => diff --git a/packages/provider-ollama/src/index.ts b/packages/provider-ollama/src/index.ts index 54f9eebffe..4ffc8c6b42 100644 --- a/packages/provider-ollama/src/index.ts +++ b/packages/provider-ollama/src/index.ts @@ -1,6 +1,7 @@ import { OLLAMA_DEFAULT_FLAGS } from './defaults.ts'; import { createOllamaProvider } from './provider.ts'; import type { ProviderModule } from '@floway-dev/provider'; +export { createOllamaPipelines } from './pipelines.ts'; export const ollamaProviderModule: ProviderModule = { create: createOllamaProvider, diff --git a/packages/provider-ollama/src/pipelines.ts b/packages/provider-ollama/src/pipelines.ts new file mode 100644 index 0000000000..b7e020f18e --- /dev/null +++ b/packages/provider-ollama/src/pipelines.ts @@ -0,0 +1,17 @@ +import type { OllamaUpstreamConfig } from './config.ts'; +import { observeOllamaAccount } from './stages/observe-account.ts'; +import { prepareOllamaRequest } from './stages/prepare-request.ts'; +import type { OllamaUpstreamState } from './state.ts'; +import { http } from '@floway-dev/http/pipeline'; +import { compose } from '@floway-dev/pipeline'; +import type { ProviderOperationPayloads, ProviderOperationResponse, ProviderPipelines, ProviderRequest } from '@floway-dev/provider'; + +export const createOllamaPipelines = (upstreamId: string, config: OllamaUpstreamConfig, state: OllamaUpstreamState): ProviderPipelines => { + const observe = observeOllamaAccount(upstreamId, config, state); + return { + openaiCompletions: compose, ProviderOperationResponse<'openaiCompletions'>>('ollama.openaiCompletions', [observe, prepareOllamaRequest(config, 'openaiCompletions'), http]), + openaiEmbeddings: compose, ProviderOperationResponse<'openaiEmbeddings'>>('ollama.openaiEmbeddings', [observe, prepareOllamaRequest(config, 'openaiEmbeddings'), http]), + openaiAudioTranscriptions: compose, ProviderOperationResponse<'openaiAudioTranscriptions'>>('ollama.openaiAudioTranscriptions', [observe, prepareOllamaRequest(config, 'openaiAudioTranscriptions'), http]), + decision: compose, ProviderOperationResponse<'decision'>>('ollama.decision', [prepareOllamaRequest(config, 'decision'), http]), + }; +}; diff --git a/packages/provider-ollama/src/provider.ts b/packages/provider-ollama/src/provider.ts index 5e2c7eb4b4..90ce7d2a71 100644 --- a/packages/provider-ollama/src/provider.ts +++ b/packages/provider-ollama/src/provider.ts @@ -29,13 +29,13 @@ import { chatFromOllamaRaw } from './chat-from-raw.ts'; import { assertOllamaUpstreamRecord, type OllamaUpstreamConfig } from './config.ts'; import { OLLAMA_DEFAULT_FLAGS } from './defaults.ts'; import { fetchOllamaCatalog, type OllamaCatalog } from './fetch-models.ts'; -import { ollamaFetchDecision, ollamaFetchOpenAIAudioTranscriptions, ollamaFetchOpenAIChatCompletions, ollamaFetchOpenAICompletions, ollamaFetchOpenAIEmbeddings, ollamaFetchAnthropicMessages, ollamaFetchAnthropicMessagesCountTokens, ollamaFetchOpenAIResponses, ollamaFetchOpenAIResponsesCompact } from './fetch.ts'; +import { ollamaFetchOpenAIAudioTranscriptions, ollamaFetchOpenAIChatCompletions, ollamaFetchOpenAICompletions, ollamaFetchOpenAIEmbeddings, ollamaFetchAnthropicMessages, ollamaFetchAnthropicMessagesCountTokens, ollamaFetchOpenAIResponses, ollamaFetchOpenAIResponsesCompact } from './fetch.ts'; +import { createOllamaPipelines } from './pipelines.ts'; import { pricingForOllamaModelKey } from './pricing.ts'; import { readOllamaUpstreamState } from './state.ts'; import { scheduleOllamaUsageProbe, runOllamaScheduledTask } from './usage-probe.ts'; import { parseAnthropicMessagesStream } from '@floway-dev/protocols/anthropic-messages'; import { type ModelEndpoints, kindForEndpoints } from '@floway-dev/protocols/common'; -import { DEFAULT_DECISION_PATHS, serializeDecisionRequest, type CanonicalDecisionRequest } from '@floway-dev/protocols/decision'; import { parseOpenAIChatCompletionsStream } from '@floway-dev/protocols/openai-chat-completions'; import { parseOpenAIResponsesStream, type OpenAIResponsesCompactionResultEx, toCompactPayloadShape } from '@floway-dev/protocols/openai-responses'; import { headersForAnthropicMessagesCall, jsonRequestBody, publicModelId, resolveEffectiveFlags, serializeModelFieldOpenAIAudioTranscriptionRequest, streamingProviderCall, type FetchInit, type FlagId, type HttpHeaderLines, type ProviderInstance, type Provider, type ProviderCallResult, type ProviderModel, type ProviderStreamParser, type UpstreamCallOptions, type UpstreamFetchOptions, type UpstreamRecord } from '@floway-dev/provider'; @@ -223,19 +223,6 @@ export const createOllamaProvider = (record: UpstreamRecord): Provider => { return { response, modelKey: rawModelId }; }, callRerank: rejectUnsupported('callRerank'), - callDecision: async (model: ProviderModel, request: CanonicalDecisionRequest, signal: AbortSignal | undefined, opts: UpstreamCallOptions) => { - const target = model.decisionTarget; - if (target === undefined) throw new Error(`Decision model ${model.id} has no outbound target`); - const rawModelId = rawModelIdOf(model); - const body = serializeDecisionRequest(target.protocol, rawModelId, request); - const response = await ollamaFetchDecision( - config, - target.path ?? DEFAULT_DECISION_PATHS[target.protocol], - { method: 'POST', body: jsonRequestBody(body), signal }, - { extraHeaders: [...opts.headers], fetcher: opts.fetcher, wrapUpstreamCall: opts.wrapUpstreamCall }, - ); - return { response, modelKey: rawModelId, target }; - }, }; return { @@ -246,6 +233,7 @@ export const createOllamaProvider = (record: UpstreamRecord): Provider => { disabledPublicModelIds: record.disabledPublicModelIds, modelPrefix: record.modelPrefix, modelsCache: record.modelsCache, + pipelines: createOllamaPipelines(record.id, config, state), instance, }; }; diff --git a/packages/provider-ollama/src/stages/observe-account.ts b/packages/provider-ollama/src/stages/observe-account.ts new file mode 100644 index 0000000000..937891269d --- /dev/null +++ b/packages/provider-ollama/src/stages/observe-account.ts @@ -0,0 +1,31 @@ +import { scheduleOllamaAccountProbe } from '../account-probe.ts'; +import type { OllamaUpstreamConfig } from '../config.ts'; +import type { OllamaUpstreamState } from '../state.ts'; +import { scheduleOllamaUsageProbe } from '../usage-probe.ts'; +import type { HttpRequestFacts } from '@floway-dev/http/pipeline'; +import { defer, defineStage, move, type Deferred } from '@floway-dev/pipeline'; +import type { ProviderResponse, ProviderServices } from '@floway-dev/provider'; + +type ProbeResponse = ProviderResponse & { 'response.ollama.probes': Deferred }; + +export const observeOllamaAccount = (upstreamId: string, config: OllamaUpstreamConfig, state: OllamaUpstreamState) => { + const observeAccount = defineStage, Pick, ProviderResponse, ProbeResponse, ProviderServices>({ + name: 'observeOllamaAccount', + through: { + request: { needs: ['request.http.callId'], consumes: [], provides: [] }, + response: { needs: ['response.http.exchange'], consumes: [], provides: ['response.ollama.probes'] }, + }, + execute: async (facts, next, use) => { + const back = await next(move({ ...facts })); + const pending: Promise[] = []; + if (back['response.http.exchange'].type === 'response') { + const call = use.httpCall(facts['request.http.callId']); + const background = (work: Promise): void => { pending.push(work); }; + scheduleOllamaUsageProbe(upstreamId, config, state, call.fetcher, background); + scheduleOllamaAccountProbe(upstreamId, config, state, call.fetcher, background); + } + return move({ ...back, 'response.ollama.probes': defer(Promise.all(pending)) }); + }, + }); + return observeAccount; +}; diff --git a/packages/provider-ollama/src/stages/prepare-request.ts b/packages/provider-ollama/src/stages/prepare-request.ts new file mode 100644 index 0000000000..96e7f05b00 --- /dev/null +++ b/packages/provider-ollama/src/stages/prepare-request.ts @@ -0,0 +1,64 @@ +import type { OllamaUpstreamConfig } from '../config.ts'; +import type { HttpRequestFacts, HttpResponseFacts } from '@floway-dev/http/pipeline'; +import { multipartBody, type HttpBody } from '@floway-dev/http/request-content'; +import { defineStage, move, secret } from '@floway-dev/pipeline'; +import { DEFAULT_DECISION_PATHS, serializeDecisionRequest } from '@floway-dev/protocols/decision'; +import { joinBaseAndPath, mergeHttpHeaders, withHttpContentType, type ProviderOperationPayloads, type ProviderRequest, type ProviderResponse, type ProviderServices } from '@floway-dev/provider'; + +type OllamaOperation = 'openaiCompletions' | 'openaiEmbeddings' | 'openaiAudioTranscriptions' | 'decision'; + +interface OllamaHttpFacts extends HttpRequestFacts { + 'request.ollama.modelKey': string; +} + +const PATHS: Record, string> = { + openaiCompletions: '/v1/completions', + openaiEmbeddings: '/v1/embeddings', + // https://github.com/ollama/ollama/blob/573386c35eac76124ffce571f4b0fefa0a7fe13c/server/routes.go#L1916-L1922 + openaiAudioTranscriptions: '/v1/audio/transcriptions', +}; + +export const prepareOllamaRequest = (config: OllamaUpstreamConfig, operation: O) => { + const prepare = defineStage, OllamaHttpFacts, HttpResponseFacts, ProviderResponse, ProviderServices>({ + name: `prepareOllama${operation.replace(/^openai/, 'OpenAI').replace(/^alpha/, 'Alpha').replace(/^rerank/, 'Rerank').replace(/^decision/, 'Decision')}`, + through: { + request: { + needs: ['request.provider.model', 'request.provider.payload', 'request.http.callId', 'request.http.headers'], + consumes: ['request.provider.model', 'request.provider.payload', 'request.http.headers'], + provides: ['request.http.url', 'request.http.method', 'request.http.headers', 'request.http.body', 'request.http.encoding', 'request.ollama.modelKey'], + }, + response: { needs: ['response.http.exchange'], consumes: [], provides: ['response.provider.modelKey', 'response.provider.called', 'response.provider.previousCalls'] }, + }, + execute: async (facts, next) => { + const { 'request.provider.model': model, 'request.provider.payload': payload, ...rest } = facts; + const modelKey = model.providerData as string; + const encoding = operation === 'openaiAudioTranscriptions' ? 'multipart' as const : 'json' as const; + let path: string; + let body: HttpBody; + if (operation === 'decision') { + const target = model.decisionTarget; + if (target === undefined) throw new Error(`Decision model ${model.id} has no outbound target`); + path = target.path ?? DEFAULT_DECISION_PATHS[target.protocol]; + body = serializeDecisionRequest(target.protocol, modelKey, payload as ProviderOperationPayloads['decision']); + } else { + path = PATHS[operation as Exclude]; + body = operation === 'openaiAudioTranscriptions' + ? multipartBody((payload as ProviderOperationPayloads['openaiAudioTranscriptions']).entries.map(entry => entry.name === 'model' ? { ...entry, value: modelKey } : entry)) + : { ...payload, model: modelKey }; + } + const base = config.apiKey ? [['Authorization', secret(`Bearer ${config.apiKey}`)] as const] : []; + const headers = mergeHttpHeaders(withHttpContentType(base, body, encoding), facts['request.http.headers']); + const back = await next(move({ + ...rest, + 'request.ollama.modelKey': modelKey, + 'request.http.url': joinBaseAndPath(config.baseUrl, path), + 'request.http.method': 'POST', + 'request.http.headers': headers, + 'request.http.body': body, + 'request.http.encoding': encoding, + })); + return move({ ...back, 'response.provider.modelKey': modelKey, 'response.provider.called': back['response.http.exchange'].type === 'response', 'response.provider.previousCalls': [] }); + }, + }); + return prepare; +}; diff --git a/packages/provider/__tests__/audio_test.ts b/packages/provider/__tests__/audio_test.ts index 62fb7541c6..25aba03ac8 100644 --- a/packages/provider/__tests__/audio_test.ts +++ b/packages/provider/__tests__/audio_test.ts @@ -4,10 +4,7 @@ import { serializeModelPathOpenAIAudioTranscriptionRequest, serializeModelFieldO import { assertEquals, assertExists } from '@floway-dev/test-utils'; test('serializeModelFieldOpenAIAudioTranscriptionRequest preserves ordered fields and file metadata while replacing model', async () => { - const file = new File([new Uint8Array([1, 2, 3, 4])], 'meeting.wav', { - type: 'audio/wav', - lastModified: 1_700_000_000_000, - }); + const file = { bytes: new Uint8Array([1, 2, 3, 4]), name: 'meeting.wav', type: 'audio/wav', lastModified: 1_700_000_000_000 }; const form = serializeModelFieldOpenAIAudioTranscriptionRequest({ entries: [ { name: 'file', value: file }, @@ -34,7 +31,7 @@ test('serializeModelPathOpenAIAudioTranscriptionRequest omits model fields selec const form = serializeModelPathOpenAIAudioTranscriptionRequest({ entries: [ { name: 'model', value: 'public-model' }, - { name: 'file', value: new File(['audio'], 'meeting.wav', { type: 'audio/wav' }) }, + { name: 'file', value: { bytes: new TextEncoder().encode('audio'), name: 'meeting.wav', type: 'audio/wav' } }, { name: 'response_format', value: 'json' }, ], }); diff --git a/packages/provider/__tests__/images_test.ts b/packages/provider/__tests__/images_test.ts index fea11462ab..e8e38848f9 100644 --- a/packages/provider/__tests__/images_test.ts +++ b/packages/provider/__tests__/images_test.ts @@ -12,7 +12,7 @@ test('serializeOpenAIImagesEditsRequest preserves reference fields and encodes m const serialized = await serializeOpenAIImagesEditsRequest({ images: [ { type: 'reference', reference: { image_url: 'https://example.test/image.png', detail: 'future-field' } }, - { type: 'upload', file: new File(['inline'], 'inline.png', { type: 'image/png' }) }, + { type: 'upload', file: { bytes: new TextEncoder().encode('inline'), name: 'inline.png', type: 'image/png' } }, ], mask: { type: 'reference', reference: { file_id: 'file-mask' } }, parameters: { prompt: 'edit', background: null }, @@ -32,7 +32,7 @@ test('serializeOpenAIImagesEditsRequest preserves reference fields and encodes m test('serializeOpenAIImagesEditsJsonPayload forces upload sources into data URLs', async () => { const body = await serializeOpenAIImagesEditsJsonPayload({ - images: [{ type: 'upload', file: new File(['image'], 'image.png', { type: 'image/png' }) }], + images: [{ type: 'upload', file: { bytes: new TextEncoder().encode('image'), name: 'image.png', type: 'image/png' } }], parameters: { prompt: 'edit' }, }, 'gpt-image-2'); assertEquals(body, { @@ -43,15 +43,16 @@ test('serializeOpenAIImagesEditsJsonPayload forces upload sources into data URLs }); test('serializeOpenAIImagesEditsRequest uses the singular field for one upload and the array field for many', async () => { - const first = new File(['first'], 'first.png', { type: 'image/png' }); - const second = new File(['second'], 'second.png', { type: 'image/png' }); + const first = { bytes: new TextEncoder().encode('first'), name: 'first.png', type: 'image/png' }; + const second = { bytes: new TextEncoder().encode('second'), name: 'second.png', type: 'image/png' }; const single = await serializeOpenAIImagesEditsRequest({ images: [{ type: 'upload', file: first }], parameters: { prompt: 'single' }, }, 'gpt-image'); assertEquals(single instanceof FormData, true); const singleForm = single as FormData; - assertEquals(singleForm.get('image'), first); + assertEquals((singleForm.get('image') as File).name, first.name); + assertEquals(new Uint8Array(await (singleForm.get('image') as File).arrayBuffer()), first.bytes); assertEquals(singleForm.getAll('image[]'), []); const multiple = await serializeOpenAIImagesEditsRequest({ @@ -60,7 +61,8 @@ test('serializeOpenAIImagesEditsRequest uses the singular field for one upload a }, 'gpt-image'); assertEquals(multiple instanceof FormData, true); const multipleForm = multiple as FormData; - assertEquals(multipleForm.getAll('image[]'), [first, second]); + assertEquals(multipleForm.getAll('image[]').map(value => (value as File).name), [first.name, second.name]); + assertEquals(await Promise.all(multipleForm.getAll('image[]').map(async value => new Uint8Array(await (value as File).arrayBuffer()))), [first.bytes, second.bytes]); assertEquals(multipleForm.get('image'), null); assertEquals(multipleForm.get('model'), 'gpt-image'); }); diff --git a/packages/provider/__tests__/invocation_test.ts b/packages/provider/__tests__/invocation_test.ts index ed390a615e..20d486eed7 100644 --- a/packages/provider/__tests__/invocation_test.ts +++ b/packages/provider/__tests__/invocation_test.ts @@ -65,6 +65,7 @@ test('providerModelOf throws the alias-row diagnostic when the candidate names a disabledPublicModelIds: [], modelPrefix: null, modelsCache: null, + pipelines: {}, instance: stubProvider(), }, model: { diff --git a/packages/provider/__tests__/pipeline_test.ts b/packages/provider/__tests__/pipeline_test.ts new file mode 100644 index 0000000000..f2512387c6 --- /dev/null +++ b/packages/provider/__tests__/pipeline_test.ts @@ -0,0 +1,21 @@ +import { expect, test } from 'vitest'; + +import type { FlagId } from '../src/flags.ts'; +import { providerModelFacts } from '../src/pipeline.ts'; +import { encodeRun, move } from '@floway-dev/pipeline'; +import { stubProviderModel } from '@floway-dev/test-utils'; + +test('provider model facts retain flags as immutable dump content while sharing unchanged metadata', () => { + const enabledFlags = new Set(['vendor-kimi']); + const model = stubProviderModel({ enabledFlags, providerData: { id: 'upstream-model' } }); + const snapshot = move(providerModelFacts(model)); + expect(snapshot.limits).toBe(model.limits); + expect(snapshot.providerData).toBe(model.providerData); + expect(snapshot.enabledFlags).toEqual(['vendor-kimi']); + expect(() => (snapshot.enabledFlags as FlagId[]).push('vendor-deepseek')).toThrow(); + enabledFlags.add('vendor-deepseek'); + expect(snapshot.enabledFlags).toEqual(['vendor-kimi']); + const encoded = encodeRun([{ type: 'stage.entered', stageId: 0, name: 'provider', parentStageId: null, facts: { 'request.provider.model': snapshot } }]); + expect(JSON.stringify(encoded)).toContain('vendor-kimi'); + expect(JSON.stringify(encoded)).not.toContain('vendor-deepseek'); +}); diff --git a/packages/provider/package.json b/packages/provider/package.json index 3cfecaf1be..f29c74d26d 100644 --- a/packages/provider/package.json +++ b/packages/provider/package.json @@ -10,6 +10,7 @@ "./model": { "import": "./src/model.ts", "types": "./src/model.ts" }, "./model-config": { "import": "./src/model-config.ts", "types": "./src/model-config.ts" }, "./model-prefix": { "import": "./src/model-prefix.ts", "types": "./src/model-prefix.ts" }, + "./pipeline": { "import": "./src/pipeline.ts", "types": "./src/pipeline.ts" }, "./browser": { "import": "./src/browser.ts", "types": "./src/browser.ts" } }, "scripts": { @@ -18,6 +19,7 @@ "dependencies": { "@discoveryjs/json-ext": "1.1.0", "@floway-dev/http": "workspace:*", + "@floway-dev/pipeline": "workspace:*", "@floway-dev/protocols": "workspace:*", "@noble/hashes": "^2.2.0", "klona": "2.0.6" diff --git a/packages/provider/src/audio.ts b/packages/provider/src/audio.ts index 3fd5b68bc4..0fa30e54d9 100644 --- a/packages/provider/src/audio.ts +++ b/packages/provider/src/audio.ts @@ -1,13 +1,6 @@ -// Buffered multipart carrier for OpenAI-compatible audio transcription. -// Entries stay ordered and may repeat, matching FormData semantics. File -// objects retain their bytes, filename, media type, and lastModified metadata; -// providers rebuild a fresh FormData for every candidate so retries never -// reuse a consumed request body. +import type { HttpFormEntry } from '@floway-dev/http/request-content'; -export interface OpenAIAudioTranscriptionFormEntry { - readonly name: string; - readonly value: string | File; -} +export type OpenAIAudioTranscriptionFormEntry = HttpFormEntry; export interface OpenAIAudioTranscriptionRequest { readonly entries: readonly OpenAIAudioTranscriptionFormEntry[]; @@ -28,7 +21,8 @@ const serializeOpenAIAudioTranscriptionRequest = ( } else if (typeof entry.value === 'string') { form.append(entry.name, entry.value); } else { - form.append(entry.name, entry.value, entry.value.name); + const file = entry.value; + form.append(entry.name, new File([file.bytes as Uint8Array], file.name, { type: file.type, lastModified: file.lastModified })); } } return form; diff --git a/packages/provider/src/http-headers.ts b/packages/provider/src/http-headers.ts new file mode 100644 index 0000000000..a242d27faf --- /dev/null +++ b/packages/provider/src/http-headers.ts @@ -0,0 +1,19 @@ +import type { HttpHeaders } from '@floway-dev/http/pipeline'; +import type { HttpBody, HttpBodyEncoding, HttpMultipartBody } from '@floway-dev/http/request-content'; +import type { Secret } from '@floway-dev/pipeline'; + +export const replaceHttpHeader = (headers: HttpHeaders, name: string, value: string | Secret): HttpHeaders => + [...headers.filter(([key]) => key.toLowerCase() !== name.toLowerCase()), [name, value]]; + +export const mergeHttpHeaders = (base: HttpHeaders, incoming: HttpHeaders): HttpHeaders => { + const supplied = new Set(incoming.map(([name]) => name.toLowerCase())); + return [...base.filter(([name]) => !supplied.has(name.toLowerCase())), ...incoming]; +}; + +export const withHttpContentType = (headers: HttpHeaders, body: HttpBody, encoding: HttpBodyEncoding): HttpHeaders => { + if (body === null || headers.some(([name]) => name.toLowerCase() === 'content-type')) return headers; + if (encoding === 'multipart') { + return [...headers, ['Content-Type', `multipart/form-data; boundary=${(body as HttpMultipartBody).boundary}`]]; + } + return encoding === 'json' ? [...headers, ['Content-Type', 'application/json']] : headers; +}; diff --git a/packages/provider/src/images.ts b/packages/provider/src/images.ts index 458bd7b4d2..c5d100a074 100644 --- a/packages/provider/src/images.ts +++ b/packages/provider/src/images.ts @@ -1,14 +1,15 @@ import { base64ToBytes, bytesToBase64, parseBase64ImageDataUrl } from './image-helpers.ts'; import { jsonRequestBody } from './json-request.ts'; import type { ReplayableBody } from './options.ts'; +import { multipartBody as httpMultipartBody, type HttpFile, type HttpFormEntry, type HttpBody, type HttpBodyEncoding } from '@floway-dev/http/request-content'; import type { OpenAIImageEditReference } from '@floway-dev/protocols/openai-images'; // Each source stores one authoritative representation. Multipart requires // upload-like sources with no extra reference fields plus scalar parameters; -// every other request uses JSON and encodes File uploads as data URLs. +// every other request uses JSON and encodes upload bytes as data URLs. interface UploadedOpenAIImagesEditsSource { type: 'upload'; - file: File; + file: HttpFile; } interface InlineOpenAIImagesEditsSource { @@ -29,7 +30,7 @@ export interface OpenAIImagesEditsRequest { parameters: Record; } -const uploadedFile = (source: OpenAIImagesEditsSource, index: number): File | null => { +const uploadedFile = (source: OpenAIImagesEditsSource, index: number): HttpFile | null => { if (source.type === 'upload') return source.file; if (source.type === 'reference') return null; const parsed = parseBase64ImageDataUrl(source.reference.image_url); @@ -40,13 +41,12 @@ const uploadedFile = (source: OpenAIImagesEditsSource, index: number): File | nu } catch { return null; } - return new File([bytes], `image-${index}`, { type: parsed.mimeType }); + return { bytes, name: `image-${index}`, type: parsed.mimeType }; }; const jsonReference = async (source: OpenAIImagesEditsSource): Promise => { if (source.type === 'inline' || source.type === 'reference') return source.reference; - const bytes = new Uint8Array(await source.file.arrayBuffer()); - return { image_url: `data:${source.file.type};base64,${bytesToBase64(bytes)}` }; + return { image_url: `data:${source.file.type};base64,${bytesToBase64(source.file.bytes)}` }; }; const jsonBody = async (request: OpenAIImagesEditsRequest): Promise> => { @@ -64,7 +64,7 @@ export const serializeOpenAIImagesEditsJsonPayload = async ( model: string, ): Promise> => ({ ...await jsonBody(request), model }); -const multipartBody = (request: OpenAIImagesEditsRequest, model: string): FormData | null => { +const multipartEntries = (request: OpenAIImagesEditsRequest, model: string): readonly HttpFormEntry[] | null => { const sources = [...request.images, ...(request.mask === undefined ? [] : [request.mask])]; const compatibleSources = sources.every(source => source.type === 'upload' @@ -73,7 +73,7 @@ const multipartBody = (request: OpenAIImagesEditsRequest, model: string): FormDa typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'); if (!compatibleSources || !compatibleParameters) return null; - const images: File[] = []; + const images: HttpFile[] = []; for (const [index, source] of request.images.entries()) { const file = uploadedFile(source, index); if (file === null) return null; @@ -82,17 +82,30 @@ const multipartBody = (request: OpenAIImagesEditsRequest, model: string): FormDa const mask = request.mask === undefined ? undefined : uploadedFile(request.mask, images.length); if (mask === null) return null; - const form = new FormData(); - for (const [name, value] of Object.entries(request.parameters)) form.append(name, String(value)); + const entries: HttpFormEntry[] = Object.entries(request.parameters).map(([name, value]) => ({ name, value: String(value) })); const imageField = images.length === 1 ? 'image' : 'image[]'; - for (const image of images) form.append(imageField, image); - if (mask !== undefined) form.append('mask', mask); - form.append('model', model); - return form; + for (const image of images) entries.push({ name: imageField, value: image }); + if (mask !== undefined) entries.push({ name: 'mask', value: mask }); + entries.push({ name: 'model', value: model }); + return entries; +}; + +export const prepareOpenAIImagesEditsBody = async (request: OpenAIImagesEditsRequest, model: string): Promise<{ body: HttpBody; encoding: HttpBodyEncoding }> => { + const entries = multipartEntries(request, model); + return entries === null + ? { body: await serializeOpenAIImagesEditsJsonPayload(request, model), encoding: 'json' } + : { body: httpMultipartBody(entries), encoding: 'multipart' }; }; export const serializeOpenAIImagesEditsRequest = async (request: OpenAIImagesEditsRequest, model: string): Promise => { - const multipart = multipartBody(request, model); - if (multipart !== null) return multipart; + const entries = multipartEntries(request, model); + if (entries !== null) { + const form = new FormData(); + for (const { name, value } of entries) { + if (typeof value === 'string') form.append(name, value); + else form.append(name, new File([value.bytes as Uint8Array], value.name, { type: value.type, lastModified: value.lastModified })); + } + return form; + } return jsonRequestBody(await serializeOpenAIImagesEditsJsonPayload(request, model)); }; diff --git a/packages/provider/src/index.ts b/packages/provider/src/index.ts index b9ebaebf45..181cf97714 100644 --- a/packages/provider/src/index.ts +++ b/packages/provider/src/index.ts @@ -7,6 +7,9 @@ export type { OpenAIResponsesInvocation, } from './invocation.ts'; export { providerModelOf } from './invocation.ts'; +export type { ProviderCallResponse, ProviderModelFacts, ProviderOperation, ProviderOperationPayloads, ProviderOperationResponse, ProviderPipeline, ProviderPipelines, ProviderRequest, ProviderResponse, ProviderRerankResponse, ProviderServices } from './pipeline.ts'; +export { providerModelFacts } from './pipeline.ts'; +export { mergeHttpHeaders, replaceHttpHeader, withHttpContentType } from './http-headers.ts'; export type { InternalDebugError } from './error.ts'; export { internalDebugErrorFields, toInternalDebugError } from './error.ts'; @@ -51,7 +54,6 @@ export type { ProviderInstance, ProviderCallResult, ProviderRerankCallResult, - ProviderDecisionCallResult, ProviderModule, AnthropicMessagesUpstreamCallOptions, ProviderOpenAIResponsesResult, @@ -61,7 +63,7 @@ export type { } from './provider.ts'; export { headersForAnthropicMessagesCall } from './anthropic-messages.ts'; export type { OpenAIImagesEditsRequest, OpenAIImagesEditsSource } from './images.ts'; -export { serializeOpenAIImagesEditsJsonPayload, serializeOpenAIImagesEditsRequest } from './images.ts'; +export { prepareOpenAIImagesEditsBody, serializeOpenAIImagesEditsJsonPayload, serializeOpenAIImagesEditsRequest } from './images.ts'; export type { OpenAIAudioTranscriptionFormEntry, OpenAIAudioTranscriptionRequest } from './audio.ts'; export { serializeModelFieldOpenAIAudioTranscriptionRequest, serializeModelPathOpenAIAudioTranscriptionRequest } from './audio.ts'; export type { ProviderStreamParser } from './streaming.ts'; diff --git a/packages/provider/src/pipeline.ts b/packages/provider/src/pipeline.ts new file mode 100644 index 0000000000..400aaacf90 --- /dev/null +++ b/packages/provider/src/pipeline.ts @@ -0,0 +1,62 @@ +import type { OpenAIAudioTranscriptionRequest } from './audio.ts'; +import type { FlagId } from './flags.ts'; +import type { OpenAIImagesEditsRequest } from './images.ts'; +import type { ProviderModel } from './model.ts'; +import type { HttpResponseFacts, HttpHeaders, HttpServices } from '@floway-dev/http/pipeline'; +import type { Pipeline } from '@floway-dev/pipeline'; +import type { RerankTarget } from '@floway-dev/protocols/common'; +import type { CanonicalDecisionRequest } from '@floway-dev/protocols/decision'; +import type { OpenAICompletionsPayload } from '@floway-dev/protocols/openai-completions'; +import type { OpenAIEmbeddingsPayload } from '@floway-dev/protocols/openai-embeddings'; +import type { OpenAIImagesGenerationsPayload } from '@floway-dev/protocols/openai-images'; +import type { CanonicalRerankRequest } from '@floway-dev/protocols/rerank'; + +export interface ProviderOperationPayloads { + alphaSearch: Record; + openaiCompletions: Omit; + openaiEmbeddings: Omit; + openaiImagesGenerations: Omit; + openaiImagesEdits: OpenAIImagesEditsRequest; + openaiAudioTranscriptions: OpenAIAudioTranscriptionRequest; + rerank: CanonicalRerankRequest; + decision: CanonicalDecisionRequest; +} + +export type ProviderOperation = keyof ProviderOperationPayloads; + +export type ProviderModelFacts = Omit & { readonly enabledFlags: readonly FlagId[] }; + +export const providerModelFacts = (model: ProviderModel): ProviderModelFacts => ({ ...model, enabledFlags: [...model.enabledFlags] }); + +export interface ProviderRequest

{ + 'request.provider.model': ProviderModelFacts; + 'request.provider.payload': P; + 'request.http.callId': number; + 'request.http.headers': HttpHeaders; +} + +export interface ProviderCallResponse extends HttpResponseFacts { + /** A preflight reply does not represent a call to the model endpoint. */ + 'response.provider.called': boolean; + /** Credential retries retain replaced endpoint replies so settlement counts each call. */ + 'response.provider.previousCalls': readonly { readonly modelKey: string }[]; +} + +export interface ProviderResponse extends ProviderCallResponse { + 'response.provider.modelKey': string; +} + +export interface ProviderRerankResponse extends ProviderResponse { + 'response.provider.rerankTarget': RerankTarget; +} + +export type ProviderOperationResponse = + O extends 'rerank' ? ProviderRerankResponse : ProviderResponse; + +export type ProviderPipeline = Pipeline< + ProviderRequest, + ProviderOperationResponse +>; + +export type ProviderPipelines = { readonly [O in ProviderOperation]?: ProviderPipeline }; +export type ProviderServices = HttpServices; diff --git a/packages/provider/src/provider.ts b/packages/provider/src/provider.ts index 84eaf13a76..af73e8aeb7 100644 --- a/packages/provider/src/provider.ts +++ b/packages/provider/src/provider.ts @@ -4,10 +4,10 @@ import type { OpenAIImagesEditsRequest } from './images.ts'; import type { ModelPrefixConfig } from './model-prefix.ts'; import type { ProviderModel, UpstreamModelsCache, UpstreamProviderKind, UpstreamRecord } from './model.ts'; import type { Fetcher } from './options.ts'; +import type { ProviderPipelines } from './pipeline.ts'; import type { ProviderScheduledOptions } from './scheduled.ts'; import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; -import type { DecisionTarget, ProtocolFrame, RerankTarget } from '@floway-dev/protocols/common'; -import type { CanonicalDecisionRequest } from '@floway-dev/protocols/decision'; +import type { ProtocolFrame, RerankTarget } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsPayload, OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; import type { OpenAICompletionsPayload } from '@floway-dev/protocols/openai-completions'; import type { OpenAIEmbeddingsPayload } from '@floway-dev/protocols/openai-embeddings'; @@ -44,6 +44,7 @@ export interface Provider { // The row's persisted catalog snapshot, mirrored so resolution does not pay // a second round trip after the row has already been loaded. modelsCache: UpstreamModelsCache | null; + pipelines: ProviderPipelines; instance: ProviderInstance; } @@ -56,12 +57,6 @@ export interface ProviderRerankCallResult extends ProviderCallResult { target: RerankTarget; } -// The target rides back so the serve layer knows which dialect shaped the -// response and can pick the matching parser. -export interface ProviderDecisionCallResult extends ProviderCallResult { - target: DecisionTarget; -} - // Streaming endpoints (Anthropic Messages / OpenAI Responses / OpenAI Chat Completions) return decoded // protocol frames directly — the provider drives the upstream fetch, parses // the SSE wire via @floway-dev/protocols, and emits the typed event stream. @@ -161,7 +156,6 @@ export interface ProviderInstance { callOpenAIImagesEdits(model: ProviderModel, request: OpenAIImagesEditsRequest, signal: AbortSignal | undefined, opts: UpstreamCallOptions): Promise; callOpenAIAudioTranscriptions(model: ProviderModel, request: OpenAIAudioTranscriptionRequest, signal: AbortSignal | undefined, opts: UpstreamCallOptions): Promise; callRerank(model: ProviderModel, request: CanonicalRerankRequest, signal: AbortSignal | undefined, opts: UpstreamCallOptions): Promise; - callDecision(model: ProviderModel, request: CanonicalDecisionRequest, signal: AbortSignal | undefined, opts: UpstreamCallOptions): Promise; } // Static, module-shaped surface each provider package exports. The gateway diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index be7bac2563..f2c0b82448 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -10,6 +10,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@floway-dev/http": "workspace:*", + "@floway-dev/pipeline": "workspace:*", "@floway-dev/provider": "workspace:*" } } diff --git a/packages/test-utils/src/index.ts b/packages/test-utils/src/index.ts index e911f1fbd0..a88fcf7e28 100644 --- a/packages/test-utils/src/index.ts +++ b/packages/test-utils/src/index.ts @@ -8,4 +8,5 @@ export { assertThrows, } from './assert.ts'; export { jsonResponse, readJsonRequest, sseResponse, testFetcher, withMockedFetch } from './mock-fetch.ts'; +export { callProviderPipeline } from './provider-pipeline.ts'; export { mockPerfTelemetryContext, noopAnthropicMessagesUpstreamCallOptions, noopUpstreamCallOptions, stubInternalModel, stubProvider, stubProviderModel, stubModelCandidate, testTelemetryModelIdentity } from './stubs.ts'; diff --git a/packages/test-utils/src/provider-pipeline.ts b/packages/test-utils/src/provider-pipeline.ts new file mode 100644 index 0000000000..b6dae79a9f --- /dev/null +++ b/packages/test-utils/src/provider-pipeline.ts @@ -0,0 +1,50 @@ +import { noopUpstreamCallOptions } from './stubs.ts'; +import { exchangeResponse, type HttpServices } from '@floway-dev/http/pipeline'; +import { move, run, setRelease } from '@floway-dev/pipeline'; +import type { Provider, ProviderCallResult, ProviderModel, ProviderOperation, ProviderOperationPayloads, ProviderOperationResponse, ProviderPipeline, ProviderRerankCallResult, ProviderRequest, UpstreamCallOptions } from '@floway-dev/provider'; +import { providerModelFacts } from '@floway-dev/provider'; + +type CallResult = + (O extends 'rerank' ? ProviderRerankCallResult : ProviderCallResult) & { readonly called: boolean; readonly previousCalls: readonly { readonly modelKey: string }[] }; + +export const callProviderPipeline = async ( + provider: Provider, + operation: O, + model: ProviderModel, + payload: ProviderOperationPayloads[O], + signal: AbortSignal | undefined = undefined, + options: UpstreamCallOptions = noopUpstreamCallOptions(), +): Promise> => { + const pipeline = provider.pipelines[operation]; + if (pipeline === undefined) throw new Error(`${provider.kind} has no ${operation} pipeline`); + const entry: ProviderRequest = move({ + 'request.provider.model': providerModelFacts(model), + 'request.provider.payload': payload, + 'request.http.callId': 0, + 'request.http.headers': [...options.headers], + }); + const services: HttpServices = { httpCall: () => ({ ...options, signal }) }; + const executed = await run, ProviderOperationResponse, HttpServices>(pipeline as ProviderPipeline, entry, services); + const facts = executed.facts; + const exchange = facts['response.http.exchange']; + if (exchange.type === 'transportFailure') { + await executed.drain(); + throw exchange.error; + } + const response = exchangeResponse(exchange); + let bytes: ArrayBuffer; + try { + bytes = await response.arrayBuffer(); + if (exchange.body !== null) setRelease(exchange.body, async () => {}); + } finally { + await executed.drain(); + } + const result = { + response: new Response(exchange.body === null ? null : bytes, { status: exchange.status, statusText: exchange.statusText, headers: response.headers }), + modelKey: facts['response.provider.modelKey'], + called: facts['response.provider.called'], + previousCalls: facts['response.provider.previousCalls'], + ...(operation === 'rerank' ? { target: (facts as ProviderOperationResponse<'rerank'>)['response.provider.rerankTarget'] } : {}), + }; + return result as CallResult; +}; diff --git a/packages/test-utils/src/stubs.ts b/packages/test-utils/src/stubs.ts index bff97498d5..29ac77ee76 100644 --- a/packages/test-utils/src/stubs.ts +++ b/packages/test-utils/src/stubs.ts @@ -90,7 +90,6 @@ export const stubProvider = (overrides: Partial = {}): Provide callOpenAIImagesEdits: overrides.callOpenAIImagesEdits ?? (() => Promise.reject(new Error('stubProvider.callOpenAIImagesEdits was called'))), callOpenAIAudioTranscriptions: overrides.callOpenAIAudioTranscriptions ?? (() => Promise.reject(new Error('stubProvider.callOpenAIAudioTranscriptions was called'))), callRerank: overrides.callRerank ?? (() => Promise.reject(new Error('stubProvider.callRerank was called'))), - callDecision: overrides.callDecision ?? (() => Promise.reject(new Error('stubProvider.callDecision was called'))), }); // Stitches together a candidate whose `model.providerModels` map carries an @@ -115,6 +114,7 @@ export const stubModelCandidate = (overrides: { disabledPublicModelIds: [], modelPrefix: null, modelsCache: null, + pipelines: {}, instance: stubProvider(), }; const modelOverrides = overrides.model ?? {}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0ded1e4fc..7a81de6772 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -163,6 +163,12 @@ importers: '@floway-dev/agent-setup': specifier: workspace:* version: link:../../packages/agent-setup + '@floway-dev/dump': + specifier: workspace:* + version: link:../../packages/dump + '@floway-dev/pipeline': + specifier: workspace:* + version: link:../../packages/pipeline '@floway-dev/protocols': specifier: workspace:* version: link:../../packages/protocols @@ -399,6 +405,9 @@ importers: '@floway-dev/interceptor': specifier: workspace:* version: link:../interceptor + '@floway-dev/pipeline': + specifier: workspace:* + version: link:../pipeline '@floway-dev/platform': specifier: workspace:* version: link:../platform @@ -475,6 +484,12 @@ importers: packages/http: dependencies: + '@discoveryjs/json-ext': + specifier: 1.1.0 + version: 1.1.0(patch_hash=481fc61758af35cca4b19ff47a4f7515c1750ef5fef628a5e2e80fb602f3e4bd) + '@floway-dev/pipeline': + specifier: workspace:* + version: link:../pipeline '@noble/hashes': specifier: ^2.2.0 version: 2.2.0 @@ -550,6 +565,9 @@ importers: '@floway-dev/http': specifier: workspace:* version: link:../http + '@floway-dev/pipeline': + specifier: workspace:* + version: link:../pipeline '@floway-dev/protocols': specifier: workspace:* version: link:../protocols @@ -566,9 +584,15 @@ importers: packages/provider-azure: dependencies: + '@floway-dev/http': + specifier: workspace:* + version: link:../http '@floway-dev/interceptor': specifier: workspace:* version: link:../interceptor + '@floway-dev/pipeline': + specifier: workspace:* + version: link:../pipeline '@floway-dev/protocols': specifier: workspace:* version: link:../protocols @@ -604,9 +628,15 @@ importers: packages/provider-codex: dependencies: + '@floway-dev/http': + specifier: workspace:* + version: link:../http '@floway-dev/interceptor': specifier: workspace:* version: link:../interceptor + '@floway-dev/pipeline': + specifier: workspace:* + version: link:../pipeline '@floway-dev/protocols': specifier: workspace:* version: link:../protocols @@ -623,9 +653,15 @@ importers: packages/provider-copilot: dependencies: + '@floway-dev/http': + specifier: workspace:* + version: link:../http '@floway-dev/interceptor': specifier: workspace:* version: link:../interceptor + '@floway-dev/pipeline': + specifier: workspace:* + version: link:../pipeline '@floway-dev/platform': specifier: workspace:* version: link:../platform @@ -651,6 +687,12 @@ importers: packages/provider-custom: dependencies: + '@floway-dev/http': + specifier: workspace:* + version: link:../http + '@floway-dev/pipeline': + specifier: workspace:* + version: link:../pipeline '@floway-dev/protocols': specifier: workspace:* version: link:../protocols @@ -664,6 +706,12 @@ importers: packages/provider-ollama: dependencies: + '@floway-dev/http': + specifier: workspace:* + version: link:../http + '@floway-dev/pipeline': + specifier: workspace:* + version: link:../pipeline '@floway-dev/protocols': specifier: workspace:* version: link:../protocols @@ -698,6 +746,12 @@ importers: packages/test-utils: dependencies: + '@floway-dev/http': + specifier: workspace:* + version: link:../http + '@floway-dev/pipeline': + specifier: workspace:* + version: link:../pipeline '@floway-dev/provider': specifier: workspace:* version: link:../provider