From de62645bccf24df68a80d5e6fc172f00829a6f3f Mon Sep 17 00:00:00 2001 From: Xiaochen Date: Mon, 9 Nov 2026 13:48:22 +0800 Subject: [PATCH 1/2] feat(copilot): support image generation and editing --- .../data-plane/openai-images/http_test.ts | 64 ++++++++++++++++ .../provider-copilot/__tests__/auth_test.ts | 26 +++++++ .../__tests__/provider_test.ts | 74 ++++++++++++++++++- packages/provider-copilot/src/auth.ts | 2 +- packages/provider-copilot/src/fetch.ts | 6 ++ packages/provider-copilot/src/provider.ts | 25 +++++-- 6 files changed, 189 insertions(+), 8 deletions(-) diff --git a/packages/gateway/__tests__/data-plane/openai-images/http_test.ts b/packages/gateway/__tests__/data-plane/openai-images/http_test.ts index 85f9004803..dd7340aff2 100644 --- a/packages/gateway/__tests__/data-plane/openai-images/http_test.ts +++ b/packages/gateway/__tests__/data-plane/openai-images/http_test.ts @@ -129,6 +129,70 @@ test('/v1/images/generations rejects model on custom upstream without /images/ge ); }); +test.each(['generations', 'edits'])('/v1/images/%s forwards through Copilot and records image usage', async operation => { + const { apiKey, repo } = await setupAppTest(); + clearInProcessCopilotTokenCache(); + const model = 'gpt-image-2.5-flare'; + const path = `/v1/images/${operation}`; + const output = { + data: [{ b64_json: PNG_B64 }], + usage: { input_tokens: 7, input_tokens_details: { text_tokens: 5, image_tokens: 2 }, output_tokens: 11, output_tokens_details: { text_tokens: 0, image_tokens: 11 } }, + }; + let calls = 0; + await withMockedFetch( + async request => { + const { pathname } = new URL(request.url); + if (pathname === '/copilot_internal/v2/token') { + return jsonResponse({ token: 'copilot-access-token', expires_at: 4102444800, refresh_in: 3600, endpoints: { api: 'https://api.individual.githubcopilot.com' } }); + } + if (pathname === '/models') { + return jsonResponse({ object: 'list', data: [{ id: model, name: 'GPT Image 2.5 Flare', capabilities: { type: 'image' }, supported_endpoints: ['/v1/images/generations', '/v1/images/edits'] }] }); + } + if (pathname === path) { + calls++; + assertEquals(request.headers.get('authorization'), 'Bearer copilot-access-token'); + if (operation === 'generations') { + assertEquals(await request.json(), { model, prompt: 'a blue bird', quality: 'future-quality' }); + } else { + const form = await request.formData(); + assertEquals(form.get('model'), model); + assertEquals(form.get('prompt'), 'a blue bird'); + assertEquals(form.get('quality'), 'future-quality'); + assertEquals(await (form.get('image') as File).text(), 'image-bytes'); + assertEquals(await (form.get('mask') as File).text(), 'mask-bytes'); + } + return new Response(JSON.stringify(output), { status: 201, headers: { 'content-type': 'application/json', 'x-request-id': 'copilot-image' } }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const headers = { 'x-api-key': apiKey.key }; + let body: string | FormData; + if (operation === 'generations') { + body = JSON.stringify({ model, prompt: 'a blue bird', quality: 'future-quality' }); + } else { + body = new FormData(); + body.set('model', model); + body.set('prompt', 'a blue bird'); + body.set('quality', 'future-quality'); + body.set('image', new File(['image-bytes'], 'image.png', { type: 'image/png' })); + body.set('mask', new File(['mask-bytes'], 'mask.png', { type: 'image/png' })); + } + const response = await requestAppWithWarmModels(path, { + method: 'POST', headers: { ...headers, ...(operation === 'generations' ? { 'content-type': 'application/json' } : {}) }, body, + }); + assertEquals(response.status, 201); + assertEquals(response.headers.get('x-request-id'), 'copilot-image'); + assertEquals(await response.json(), output); + await flushAsyncWork(); + }, + ); + assertEquals(calls, 1); + const usageRows = (await repo.usage.listAll()).filter(row => row.model === model); + assertEquals(usageRows.length, 1); + assertEquals(tokenCountsFromUsage(usageRows[0]), { input: 5, input_image: 2, output_image: 11 }); +}); + test('/v1/images/generations forwards a JSON request through a custom upstream and records usage', async () => { const { apiKey, repo } = await setupAppTest(); clearInProcessCopilotTokenCache(); diff --git a/packages/provider-copilot/__tests__/auth_test.ts b/packages/provider-copilot/__tests__/auth_test.ts index ffccf01704..f929db496c 100644 --- a/packages/provider-copilot/__tests__/auth_test.ts +++ b/packages/provider-copilot/__tests__/auth_test.ts @@ -86,6 +86,32 @@ const mockTokenAndCapture = async ( assert(captured); }; +test('Copilot authentication leaves multipart boundary generation to the transport', async () => { + await installRepoAndClearCache(); + const body = new FormData(); + body.set('model', 'image-preview'); + body.set('image', new File(['image-bytes'], 'image.png', { type: 'image/png' })); + let calls = 0; + await withMockedFetch( + async request => { + if (new URL(request.url).pathname === '/copilot_internal/v2/token') return tokenResponse(); + calls++; + expect(request.headers.get('content-type')).toMatch(/^multipart\/form-data; boundary=/); + expect(request.headers.get('authorization')).toBe('Bearer tok-test'); + const form = await request.formData(); + expect(form.get('model')).toBe('image-preview'); + expect(await (form.get('image') as File).text()).toBe('image-bytes'); + return jsonResponse({}); + }, + async () => { + await copilotAuthedFetch('/v1/images/edits', { method: 'POST', body }, + { id: UPSTREAM_ID, githubHost: 'github.com', githubToken: 'ghu_test' }, + { fetcher: directFetcher, wrapUpstreamCall: identityWrapUpstreamCall }); + }, + ); + expect(calls).toBe(1); +}); + const runAuthedFetch = async (fetcher: Fetcher, signal?: AbortSignal): Promise => { await installRepoAndClearCache(); return await copilotAuthedFetch( diff --git a/packages/provider-copilot/__tests__/provider_test.ts b/packages/provider-copilot/__tests__/provider_test.ts index 84d826bb2b..9c76c3eb13 100644 --- a/packages/provider-copilot/__tests__/provider_test.ts +++ b/packages/provider-copilot/__tests__/provider_test.ts @@ -112,6 +112,7 @@ interface CopilotModelFixture { id: string; display_name?: string; supported_endpoints?: string[]; + type?: string; reasoningEfforts?: string[]; maxContextWindowTokens?: number; maxPromptTokens?: number; @@ -127,7 +128,7 @@ const copilotModels = (models: CopilotModelFixture[]) => ({ version: '1', supported_endpoints: model.supported_endpoints ?? [], capabilities: { - type: 'chat', + type: model.type ?? 'chat', limits: { ...(model.maxContextWindowTokens !== undefined ? { max_context_window_tokens: model.maxContextWindowTokens } : {}), ...(model.maxPromptTokens !== undefined ? { max_prompt_tokens: model.maxPromptTokens } : {}), @@ -138,6 +139,77 @@ const copilotModels = (models: CopilotModelFixture[]) => ({ })), }); +test.each([ + { paths: ['/v1/images/generations', '/v1/images/edits'], endpoints: { openaiImagesGenerations: {}, openaiImagesEdits: {} } }, + { paths: ['/images/generations'], endpoints: { openaiImagesGenerations: {} } }, + { paths: ['/images/edits'], endpoints: { openaiImagesEdits: {} } }, + { paths: [], endpoints: {} }, +])('Copilot image capabilities follow the advertised endpoints: $paths', async ({ paths, endpoints }) => { + const { copilotUpstream } = await setupCopilotTest(); + const provider = createCopilotProvider(copilotUpstream).instance; + await withMockedFetch( + request => { + const { pathname } = new URL(request.url); + if (pathname === '/copilot_internal/v2/token') { + return jsonResponse({ token: 'copilot-access-token', expires_at: 4102444800, refresh_in: 3600, endpoints: { api: 'https://api.individual.githubcopilot.com' } }); + } + if (pathname === '/models') { + return jsonResponse(copilotModels([{ id: 'gpt-image-2.5-flare', display_name: 'GPT Image 2.5 Flare', type: 'image', supported_endpoints: paths }])); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const [model] = await provider.getProvidedModels(directFetcher); + assertEquals(model.id, 'gpt-image-2.5-flare'); + assertEquals(model.display_name, 'GPT Image 2.5 Flare'); + assertEquals(model.endpoints, endpoints); + assertEquals(model.kind === 'image', paths.length > 0); + assertEquals(model.chat, undefined); + }, + ); +}); + +test('Copilot image edits preserve JSON references, parameters, and upstream errors', async () => { + const { copilotUpstream } = await setupCopilotTest(); + const provider = createCopilotProvider(copilotUpstream).instance; + const failureBody = '{"error":{"message":"image policy rejected","type":"invalid_request_error"}}'; + let calls = 0; + await withMockedFetch( + async request => { + const { pathname } = new URL(request.url); + if (pathname === '/copilot_internal/v2/token') { + return jsonResponse({ token: 'copilot-access-token', expires_at: 4102444800, refresh_in: 3600, endpoints: { api: 'https://api.individual.githubcopilot.com' } }); + } + if (pathname === '/models') { + return jsonResponse(copilotModels([{ id: 'image-preview', type: 'image', supported_endpoints: ['/v1/images/edits'] }])); + } + if (pathname === '/v1/images/edits') { + calls++; + assertEquals(request.headers.get('authorization'), 'Bearer copilot-access-token'); + assertEquals(await request.json(), { + model: 'image-preview', prompt: 'make it blue', quality: 'future-quality', + images: [{ image_url: 'https://example.com/image.png', detail: 'high' }], mask: { file_id: 'file-mask' }, + }); + return new Response(failureBody, { status: 403, headers: { 'content-type': 'application/json', 'x-request-id': 'image-error' } }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const [model] = await provider.getProvidedModels(directFetcher); + const result = await provider.callOpenAIImagesEdits(model, { + images: [{ type: 'reference', reference: { image_url: 'https://example.com/image.png', detail: 'high' } }], + mask: { type: 'reference', reference: { file_id: 'file-mask' } }, + parameters: { prompt: 'make it blue', quality: 'future-quality' }, + }, undefined, noopUpstreamCallOptions()); + assertEquals(result.modelKey, 'image-preview'); + assertEquals(result.response.status, 403); + assertEquals(result.response.headers.get('x-request-id'), 'image-error'); + assertEquals(await result.response.text(), failureBody); + }, + ); + assertEquals(calls, 1); +}); + test('Copilot provider exposes the highest-priority non-Claude endpoint', async () => { const { copilotUpstream } = await setupCopilotTest(); const instance = createCopilotProvider(copilotUpstream); diff --git a/packages/provider-copilot/src/auth.ts b/packages/provider-copilot/src/auth.ts index 75fe443235..87a103ca98 100644 --- a/packages/provider-copilot/src/auth.ts +++ b/packages/provider-copilot/src/auth.ts @@ -241,7 +241,7 @@ export async function copilotAuthedFetch(path: string, init: FetchInit, auth: Co if (ownedInit === undefined) throw new Error('Copilot request ownership missing before dispatch'); const headers = new Headers(ownedInit.headers); headers.set('Authorization', `Bearer ${entry.token}`); - headers.set('Content-Type', 'application/json'); + if (!(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()); diff --git a/packages/provider-copilot/src/fetch.ts b/packages/provider-copilot/src/fetch.ts index 789e449250..3c394c5cab 100644 --- a/packages/provider-copilot/src/fetch.ts +++ b/packages/provider-copilot/src/fetch.ts @@ -73,6 +73,12 @@ export const copilotFetchAnthropicMessagesCountTokens = (config: CopilotFetchCon copilotFetchInternal(config, '/v1/messages/count_tokens', init, options); export const copilotFetchOpenAIEmbeddings = (config: CopilotFetchConfig, init: FetchInit, options: CopilotDataPlaneFetchOptions): Promise => copilotFetchInternal(config, '/embeddings', init, options); +// Copilot advertises the OpenAI Images paths in each image model's supported_endpoints. +// https://github.com/caozhiyuan/copilot-api/blob/ab09bd671fc984896372b0b56f1c8211847808c0/src/services/copilot/images.ts +export const copilotFetchOpenAIImagesGenerations = (config: CopilotFetchConfig, init: FetchInit, options: CopilotDataPlaneFetchOptions): Promise => + copilotFetchInternal(config, '/v1/images/generations', init, options); +export const copilotFetchOpenAIImagesEdits = (config: CopilotFetchConfig, init: FetchInit, options: CopilotDataPlaneFetchOptions): Promise => + 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 System One path it advertises in diff --git a/packages/provider-copilot/src/provider.ts b/packages/provider-copilot/src/provider.ts index 2ef0eea70b..9d6695a3a0 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, copilotFetchOpenAIResponses, copilotFetchSystemOne, type CopilotDataPlaneFetchOptions } from './fetch.ts'; +import { copilotFetchOpenAIChatCompletions, copilotFetchOpenAIEmbeddings, copilotFetchOpenAIImagesGenerations, copilotFetchOpenAIImagesEdits, copilotFetchAnthropicMessages, copilotFetchAnthropicMessagesCountTokens, copilotFetchOpenAIResponses, copilotFetchSystemOne, 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'; @@ -24,7 +24,7 @@ import { type ModelEndpointKey, type ModelEndpoints, type ProtocolFrame, isFastS import { DEFAULT_DECISION_PATHS, serializeDecisionRequest, type CanonicalDecisionRequest } from '@floway-dev/protocols/decision'; 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, 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'; +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'; interface CopilotProviderData { rawModels: CopilotRawModel[]; @@ -116,6 +116,12 @@ const copilotModelEndpoints = (rawModels: readonly CopilotRawModel[]): ModelEndp return { openaiChatCompletions: {} }; } + const imageEndpoints: ModelEndpoints = { + ...(rawModels.some(model => rawModelSupportsEndpoint(model, 'openaiImagesGenerations')) ? { openaiImagesGenerations: {} } : {}), + ...(rawModels.some(model => rawModelSupportsEndpoint(model, 'openaiImagesEdits')) ? { openaiImagesEdits: {} } : {}), + }; + if (Object.keys(imageEndpoints).length > 0) return imageEndpoints; + if (rawModels.some(model => rawModelSupportsEndpoint(model, 'openaiEmbeddings'))) return { openaiEmbeddings: {} }; // A decision model is a family of one: no other endpoint family shares its // row, so this branch never competes with the chat/embeddings collapse above. @@ -498,10 +504,17 @@ export const createCopilotProvider = (record: UpstreamRecord): Provider => { return { response, modelKey: rawModel.id }; }, callOpenAIEmbeddings: (model, body, signal, opts) => call(copilotFetchOpenAIEmbeddings, copilotOpenAIEmbeddingsBody(body), signal, rawModelFor(model, 'openaiEmbeddings'), [...opts.headers], opts), - // Copilot has no /images/* upstream; catalog never emits a kind='image' - // model, so these stubs are unreachable. - callOpenAIImagesGenerations: rejectUnsupported('callOpenAIImagesGenerations'), - callOpenAIImagesEdits: rejectUnsupported('callOpenAIImagesEdits'), + callOpenAIImagesGenerations: (model, body, signal, opts) => call(copilotFetchOpenAIImagesGenerations, body, signal, rawModelFor(model, 'openaiImagesGenerations'), [...opts.headers], opts), + callOpenAIImagesEdits: async (model, request, signal, opts) => { + const rawModel = rawModelFor(model, 'openaiImagesEdits'); + const body = await serializeOpenAIImagesEditsRequest(request, rawModel.id); + const response = await copilotFetchOpenAIImagesEdits( + upstreamConfig, + { method: 'POST', body, signal }, + { extraHeaders: [...opts.headers], fetcher: opts.fetcher, wrapUpstreamCall: opts.wrapUpstreamCall, waitUntil: opts.waitUntil }, + ); + return { response, modelKey: rawModel.id }; + }, callOpenAIAudioTranscriptions: rejectUnsupported('callOpenAIAudioTranscriptions'), callRerank: rejectUnsupported('callRerank'), callDecision: async (model: ProviderModel, request: CanonicalDecisionRequest, signal: AbortSignal | undefined, opts: UpstreamCallOptions) => { From e3fabf78209f86d2141450e0c6967f973c39d08d Mon Sep 17 00:00:00 2001 From: Xiaochen Date: Mon, 9 Nov 2026 14:17:22 +0800 Subject: [PATCH 2/2] refactor(provider): keep media types with request encoding --- packages/provider-azure/src/provider.ts | 4 ++-- .../provider-copilot/__tests__/auth_test.ts | 9 +++++++- .../__tests__/provider_test.ts | 17 +++++++++----- packages/provider-copilot/src/auth.ts | 1 - packages/provider-copilot/src/fetch-models.ts | 1 - packages/provider-copilot/src/fetch.ts | 4 ++-- packages/provider-copilot/src/provider.ts | 23 +++++++++---------- packages/provider-custom/src/provider.ts | 4 ++-- packages/provider/__tests__/images_test.ts | 13 +++++++---- .../provider/__tests__/json-request_test.ts | 12 +++++++++- packages/provider/src/images.ts | 11 +++++---- packages/provider/src/index.ts | 2 +- packages/provider/src/json-request.ts | 5 ++++ 13 files changed, 68 insertions(+), 38 deletions(-) diff --git a/packages/provider-azure/src/provider.ts b/packages/provider-azure/src/provider.ts index b5d82564f6..0c418b5994 100644 --- a/packages/provider-azure/src/provider.ts +++ b/packages/provider-azure/src/provider.ts @@ -115,8 +115,8 @@ export const createAzureProvider = (record: UpstreamRecord): Provider => { callOpenAIImagesGenerations: (model, body, signal, opts) => callNonStreaming(azureFetchOpenAIImagesGenerations, model, body, signal, [...opts.headers], opts), callOpenAIImagesEdits: async (model, request, signal, opts) => { const upstreamModelId = upstreamModelIdOf(model); - const body = await serializeOpenAIImagesEditsRequest(request, upstreamModelId); - const response = await azureFetchOpenAIImagesEdits(azure.config, { method: 'POST', body, signal }, { extraHeaders: [...opts.headers], fetcher: opts.fetcher, wrapUpstreamCall: opts.wrapUpstreamCall }); + const serialized = await serializeOpenAIImagesEditsRequest(request, upstreamModelId); + const response = await azureFetchOpenAIImagesEdits(azure.config, { method: 'POST', ...serialized, signal }, { extraHeaders: [...opts.headers], fetcher: opts.fetcher, wrapUpstreamCall: opts.wrapUpstreamCall }); return { response, modelKey: upstreamModelId }; }, callOpenAIAudioTranscriptions: async (model, request, signal, opts) => { diff --git a/packages/provider-copilot/__tests__/auth_test.ts b/packages/provider-copilot/__tests__/auth_test.ts index f929db496c..fdeb0ac803 100644 --- a/packages/provider-copilot/__tests__/auth_test.ts +++ b/packages/provider-copilot/__tests__/auth_test.ts @@ -54,6 +54,7 @@ const installRepoAndClearCache = async () => { const mockTokenAndCapture = async ( extraHeaders: HttpHeaderLines | undefined, assert: (headers: Headers) => void, + contentType = 'application/json', ): Promise => { await installRepoAndClearCache(); let captured: Headers | null = null; @@ -75,7 +76,7 @@ const mockTokenAndCapture = async ( async () => { await copilotAuthedFetch( '/v1/messages', - { method: 'POST', body: '{}' }, + { method: 'POST', body: '{}', headers: { 'content-type': contentType } }, { id: UPSTREAM_ID, githubHost: 'github.com', githubToken: 'ghu_test' }, extraHeaders ? { headers: extraHeaders, fetcher: directFetcher, wrapUpstreamCall: identityWrapUpstreamCall } : { fetcher: directFetcher, wrapUpstreamCall: identityWrapUpstreamCall }, ); @@ -86,6 +87,12 @@ const mockTokenAndCapture = async ( assert(captured); }; +test('Copilot authentication preserves the request media type', async () => { + await mockTokenAndCapture(undefined, headers => { + assertEquals(headers.get('content-type'), 'application/vnd.test+json'); + }, 'application/vnd.test+json'); +}); + test('Copilot authentication leaves multipart boundary generation to the transport', async () => { await installRepoAndClearCache(); const body = new FormData(); diff --git a/packages/provider-copilot/__tests__/provider_test.ts b/packages/provider-copilot/__tests__/provider_test.ts index 9c76c3eb13..21e17f034c 100644 --- a/packages/provider-copilot/__tests__/provider_test.ts +++ b/packages/provider-copilot/__tests__/provider_test.ts @@ -139,6 +139,11 @@ const copilotModels = (models: CopilotModelFixture[]) => ({ })), }); +const copilotTokenResponse = (): Response => jsonResponse({ + token: 'copilot-access-token', expires_at: 4102444800, refresh_in: 3600, + endpoints: { api: 'https://api.individual.githubcopilot.com' }, +}); + test.each([ { paths: ['/v1/images/generations', '/v1/images/edits'], endpoints: { openaiImagesGenerations: {}, openaiImagesEdits: {} } }, { paths: ['/images/generations'], endpoints: { openaiImagesGenerations: {} } }, @@ -150,9 +155,7 @@ test.each([ await withMockedFetch( request => { const { pathname } = new URL(request.url); - if (pathname === '/copilot_internal/v2/token') { - return jsonResponse({ token: 'copilot-access-token', expires_at: 4102444800, refresh_in: 3600, endpoints: { api: 'https://api.individual.githubcopilot.com' } }); - } + if (pathname === '/copilot_internal/v2/token') return copilotTokenResponse(); if (pathname === '/models') { return jsonResponse(copilotModels([{ id: 'gpt-image-2.5-flare', display_name: 'GPT Image 2.5 Flare', type: 'image', supported_endpoints: paths }])); } @@ -177,15 +180,14 @@ test('Copilot image edits preserve JSON references, parameters, and upstream err await withMockedFetch( async request => { const { pathname } = new URL(request.url); - if (pathname === '/copilot_internal/v2/token') { - return jsonResponse({ token: 'copilot-access-token', expires_at: 4102444800, refresh_in: 3600, endpoints: { api: 'https://api.individual.githubcopilot.com' } }); - } + if (pathname === '/copilot_internal/v2/token') return copilotTokenResponse(); if (pathname === '/models') { return jsonResponse(copilotModels([{ id: 'image-preview', type: 'image', supported_endpoints: ['/v1/images/edits'] }])); } if (pathname === '/v1/images/edits') { calls++; assertEquals(request.headers.get('authorization'), 'Bearer copilot-access-token'); + assertEquals(request.headers.get('content-type'), 'application/json'); assertEquals(await request.json(), { model: 'image-preview', prompt: 'make it blue', quality: 'future-quality', images: [{ image_url: 'https://example.com/image.png', detail: 'high' }], mask: { file_id: 'file-mask' }, @@ -454,6 +456,7 @@ test('Copilot provider runs the OpenAI Responses boundary chain on the compact p return jsonResponse(copilotModels([{ id: 'gpt-resp', supported_endpoints: ['/responses'] }])); } if (url.pathname === '/responses') { + assertEquals(request.headers.get('content-type'), 'application/json'); openaiResponsesBody = (await request.json()) as Record; visionHeader = request.headers.get('copilot-vision-request'); initiatorHeader = request.headers.get('x-initiator'); @@ -645,6 +648,7 @@ test('Copilot provider forces stream=true for streaming endpoints and leaves cou } const path = url.pathname; + assertEquals(request.headers.get('content-type'), 'application/json'); bodies[path] = (await request.json()) as Record; if (['/chat/completions', '/responses', '/v1/messages'].includes(path)) { @@ -1657,6 +1661,7 @@ test('Copilot provider derives decision endpoints and forwards the System One bo ); } if (url.pathname === '/v1/systemone') { + assertEquals(request.headers.get('content-type'), 'application/json'); sent.push((await request.json()) as Record); return jsonResponse({ model: 'microsoft-decision-1', diff --git a/packages/provider-copilot/src/auth.ts b/packages/provider-copilot/src/auth.ts index 87a103ca98..28c5647f18 100644 --- a/packages/provider-copilot/src/auth.ts +++ b/packages/provider-copilot/src/auth.ts @@ -241,7 +241,6 @@ export async function copilotAuthedFetch(path: string, init: FetchInit, auth: Co if (ownedInit === undefined) throw new Error('Copilot request ownership missing before dispatch'); const headers = new Headers(ownedInit.headers); headers.set('Authorization', `Bearer ${entry.token}`); - if (!(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()); diff --git a/packages/provider-copilot/src/fetch-models.ts b/packages/provider-copilot/src/fetch-models.ts index 6614b79b7a..75e319a88c 100644 --- a/packages/provider-copilot/src/fetch-models.ts +++ b/packages/provider-copilot/src/fetch-models.ts @@ -22,7 +22,6 @@ const isCopilotModelsResponse = (value: unknown): value is CopilotModelsResponse const MODELS_HEADER_OVERRIDES: HttpHeaderLines = [ ['openai-intent', 'model-access'], ['x-interaction-type', 'model-access'], - ['content-type', ''], ]; export const fetchCopilotModels = (config: CopilotFetchConfig, fetcher: Fetcher): Promise => diff --git a/packages/provider-copilot/src/fetch.ts b/packages/provider-copilot/src/fetch.ts index 3c394c5cab..d849ca6c0f 100644 --- a/packages/provider-copilot/src/fetch.ts +++ b/packages/provider-copilot/src/fetch.ts @@ -73,10 +73,10 @@ export const copilotFetchAnthropicMessagesCountTokens = (config: CopilotFetchCon copilotFetchInternal(config, '/v1/messages/count_tokens', init, options); export const copilotFetchOpenAIEmbeddings = (config: CopilotFetchConfig, init: FetchInit, options: CopilotDataPlaneFetchOptions): Promise => copilotFetchInternal(config, '/embeddings', init, options); -// Copilot advertises the OpenAI Images paths in each image model's supported_endpoints. -// https://github.com/caozhiyuan/copilot-api/blob/ab09bd671fc984896372b0b56f1c8211847808c0/src/services/copilot/images.ts +// https://developers.openai.com/api/reference/resources/images/methods/generate export const copilotFetchOpenAIImagesGenerations = (config: CopilotFetchConfig, init: FetchInit, options: CopilotDataPlaneFetchOptions): Promise => copilotFetchInternal(config, '/v1/images/generations', init, options); +// https://developers.openai.com/api/reference/resources/images/methods/edit export const copilotFetchOpenAIImagesEdits = (config: CopilotFetchConfig, init: FetchInit, options: CopilotDataPlaneFetchOptions): Promise => copilotFetchInternal(config, '/v1/images/edits', init, options); export const copilotFetchModels = (config: CopilotFetchConfig, init: FetchInit, options: CopilotDataPlaneFetchOptions): Promise => diff --git a/packages/provider-copilot/src/provider.ts b/packages/provider-copilot/src/provider.ts index 9d6695a3a0..a743aeb8f9 100644 --- a/packages/provider-copilot/src/provider.ts +++ b/packages/provider-copilot/src/provider.ts @@ -24,7 +24,7 @@ import { type ModelEndpointKey, type ModelEndpoints, type ProtocolFrame, isFastS import { DEFAULT_DECISION_PATHS, serializeDecisionRequest, type CanonicalDecisionRequest } from '@floway-dev/protocols/decision'; 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'; +import { eventResult, getProviderRepo, headersForAnthropicMessagesCall, jsonRequest, 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'; interface CopilotProviderData { rawModels: CopilotRawModel[]; @@ -116,11 +116,10 @@ const copilotModelEndpoints = (rawModels: readonly CopilotRawModel[]): ModelEndp return { openaiChatCompletions: {} }; } - const imageEndpoints: ModelEndpoints = { - ...(rawModels.some(model => rawModelSupportsEndpoint(model, 'openaiImagesGenerations')) ? { openaiImagesGenerations: {} } : {}), - ...(rawModels.some(model => rawModelSupportsEndpoint(model, 'openaiImagesEdits')) ? { openaiImagesEdits: {} } : {}), - }; - if (Object.keys(imageEndpoints).length > 0) return imageEndpoints; + const imageEndpoints: ModelEndpoints = {}; + if (rawModels.some(model => rawModelSupportsEndpoint(model, 'openaiImagesGenerations'))) imageEndpoints.openaiImagesGenerations = {}; + if (rawModels.some(model => rawModelSupportsEndpoint(model, 'openaiImagesEdits'))) imageEndpoints.openaiImagesEdits = {}; + if (imageEndpoints.openaiImagesGenerations || imageEndpoints.openaiImagesEdits) return imageEndpoints; if (rawModels.some(model => rawModelSupportsEndpoint(model, 'openaiEmbeddings'))) return { openaiEmbeddings: {} }; // A decision model is a family of one: no other endpoint family shares its @@ -238,7 +237,7 @@ export const createCopilotProvider = (record: UpstreamRecord): Provider => { upstreamConfig, { method: 'POST', - body: jsonRequestBody({ ...body, model: rawModel.id }), + ...jsonRequest({ ...body, model: rawModel.id }), signal, }, { extraHeaders: headers, fetcher: opts.fetcher, wrapUpstreamCall: opts.wrapUpstreamCall, waitUntil: opts.waitUntil }, @@ -260,7 +259,7 @@ export const createCopilotProvider = (record: UpstreamRecord): Provider => { upstreamConfig, { method: 'POST', - body: jsonRequestBody({ ...body, stream: true, model: rawModel.id }), + ...jsonRequest({ ...body, stream: true, model: rawModel.id }), signal, }, { extraHeaders: headers, fetcher: opts.fetcher, wrapUpstreamCall: opts.wrapUpstreamCall, waitUntil: opts.waitUntil }, @@ -423,7 +422,7 @@ export const createCopilotProvider = (record: UpstreamRecord): Provider => { const triggered = { ...wireBody, input: [...input, COMPACTION_TRIGGER], stream: false, model: rawModel.id }; const response = await copilotFetchOpenAIResponses( upstreamConfig, - { method: 'POST', body: jsonRequestBody(triggered), signal }, + { method: 'POST', ...jsonRequest(triggered), signal }, { extraHeaders: [...ctx.headers], fetcher: opts.fetcher, wrapUpstreamCall: opts.wrapUpstreamCall, waitUntil: opts.waitUntil }, ); if (!response.ok) return { action: 'compact', ok: false, response, modelKey: rawModel.id }; @@ -507,10 +506,10 @@ export const createCopilotProvider = (record: UpstreamRecord): Provider => { callOpenAIImagesGenerations: (model, body, signal, opts) => call(copilotFetchOpenAIImagesGenerations, body, signal, rawModelFor(model, 'openaiImagesGenerations'), [...opts.headers], opts), callOpenAIImagesEdits: async (model, request, signal, opts) => { const rawModel = rawModelFor(model, 'openaiImagesEdits'); - const body = await serializeOpenAIImagesEditsRequest(request, rawModel.id); + const serialized = await serializeOpenAIImagesEditsRequest(request, rawModel.id); const response = await copilotFetchOpenAIImagesEdits( upstreamConfig, - { method: 'POST', body, signal }, + { method: 'POST', ...serialized, signal }, { extraHeaders: [...opts.headers], fetcher: opts.fetcher, wrapUpstreamCall: opts.wrapUpstreamCall, waitUntil: opts.waitUntil }, ); return { response, modelKey: rawModel.id }; @@ -525,7 +524,7 @@ export const createCopilotProvider = (record: UpstreamRecord): Provider => { const response = await copilotFetchSystemOne( upstreamConfig, target.path ?? DEFAULT_DECISION_PATHS[target.protocol], - { method: 'POST', body: jsonRequestBody(body), signal }, + { method: 'POST', ...jsonRequest(body), signal }, { extraHeaders: [...opts.headers], fetcher: opts.fetcher, wrapUpstreamCall: opts.wrapUpstreamCall }, ); return { response, modelKey: rawModel.id, target }; diff --git a/packages/provider-custom/src/provider.ts b/packages/provider-custom/src/provider.ts index f536f0fbcf..cccb1ff1fc 100644 --- a/packages/provider-custom/src/provider.ts +++ b/packages/provider-custom/src/provider.ts @@ -255,8 +255,8 @@ export const createCustomProvider = (record: UpstreamRecord): Provider => { callOpenAIImagesGenerations: (model, body, signal, opts) => call(customFetchOpenAIImagesGenerations, model, body, signal, headersForCall(opts.headers), opts), callOpenAIImagesEdits: async (model, request, signal, opts) => { const rawModelId = rawModelIdOf(model); - const body = await serializeOpenAIImagesEditsRequest(request, rawModelId); - const response = await customFetchOpenAIImagesEdits(config, { method: 'POST', body, signal }, { extraHeaders: headersForCall(opts.headers), fetcher: opts.fetcher, wrapUpstreamCall: opts.wrapUpstreamCall }); + const serialized = await serializeOpenAIImagesEditsRequest(request, rawModelId); + const response = await customFetchOpenAIImagesEdits(config, { method: 'POST', ...serialized, signal }, { extraHeaders: headersForCall(opts.headers), fetcher: opts.fetcher, wrapUpstreamCall: opts.wrapUpstreamCall }); return { response, modelKey: rawModelId }; }, callOpenAIAudioTranscriptions: async (model, request, signal, opts) => { diff --git a/packages/provider/__tests__/images_test.ts b/packages/provider/__tests__/images_test.ts index fea11462ab..93ec6da410 100644 --- a/packages/provider/__tests__/images_test.ts +++ b/packages/provider/__tests__/images_test.ts @@ -3,8 +3,9 @@ import { test } from 'vitest'; import { serializeOpenAIImagesEditsJsonPayload, serializeOpenAIImagesEditsRequest } from '../src/images.ts'; import { assertEquals } from '@floway-dev/test-utils'; -const parseJsonBody = async (body: Awaited>): Promise => { +const parseJsonBody = async ({ body, headers }: Awaited>): Promise => { if (body instanceof FormData) throw new Error('expected a JSON image-edit body'); + assertEquals(new Headers(headers).get('content-type'), 'application/json'); return await new Response(body.open()).json(); }; @@ -49,8 +50,9 @@ test('serializeOpenAIImagesEditsRequest uses the singular field for one upload a images: [{ type: 'upload', file: first }], parameters: { prompt: 'single' }, }, 'gpt-image'); - assertEquals(single instanceof FormData, true); - const singleForm = single as FormData; + assertEquals(single.body instanceof FormData, true); + assertEquals(new Headers(single.headers).has('content-type'), false); + const singleForm = single.body as FormData; assertEquals(singleForm.get('image'), first); assertEquals(singleForm.getAll('image[]'), []); @@ -58,8 +60,9 @@ test('serializeOpenAIImagesEditsRequest uses the singular field for one upload a images: [{ type: 'upload', file: first }, { type: 'upload', file: second }], parameters: { prompt: 'multiple' }, }, 'gpt-image'); - assertEquals(multiple instanceof FormData, true); - const multipleForm = multiple as FormData; + assertEquals(multiple.body instanceof FormData, true); + assertEquals(new Headers(multiple.headers).has('content-type'), false); + const multipleForm = multiple.body as FormData; assertEquals(multipleForm.getAll('image[]'), [first, second]); assertEquals(multipleForm.get('image'), null); assertEquals(multipleForm.get('model'), 'gpt-image'); diff --git a/packages/provider/__tests__/json-request_test.ts b/packages/provider/__tests__/json-request_test.ts index f6a2017f67..b3c4faa35c 100644 --- a/packages/provider/__tests__/json-request_test.ts +++ b/packages/provider/__tests__/json-request_test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest'; -import { jsonRequestBody } from '../src/json-request.ts'; +import { jsonRequest, jsonRequestBody } from '../src/json-request.ts'; import { jsonInteger } from '@floway-dev/protocols/common'; const readChunks = async (body: ReturnType): Promise => { @@ -9,6 +9,16 @@ const readChunks = async (body: ReturnType): Promise { + const value = { text: 'before' }; + const { body, headers } = jsonRequest(value); + value.text = 'after'; + + expect(new Headers(headers).get('content-type')).toBe('application/json'); + expect(await new Response(body.open()).json()).toEqual({ text: 'before' }); + expect(await new Response(body.open()).json()).toEqual({ text: 'before' }); +}); + test('preserves exact raw integer tokens through snapshots and repeated request serialization', async () => { const value = { properties: { items: { maxItems: jsonInteger(9007199254740993n) } } }; const expected = '{"properties":{"items":{"maxItems":9007199254740993}}}'; diff --git a/packages/provider/src/images.ts b/packages/provider/src/images.ts index 458bd7b4d2..dd748986a8 100644 --- a/packages/provider/src/images.ts +++ b/packages/provider/src/images.ts @@ -1,5 +1,5 @@ import { base64ToBytes, bytesToBase64, parseBase64ImageDataUrl } from './image-helpers.ts'; -import { jsonRequestBody } from './json-request.ts'; +import { jsonRequest } from './json-request.ts'; import type { ReplayableBody } from './options.ts'; import type { OpenAIImageEditReference } from '@floway-dev/protocols/openai-images'; @@ -91,8 +91,11 @@ const multipartBody = (request: OpenAIImagesEditsRequest, model: string): FormDa return form; }; -export const serializeOpenAIImagesEditsRequest = async (request: OpenAIImagesEditsRequest, model: string): Promise => { +export const serializeOpenAIImagesEditsRequest = async ( + request: OpenAIImagesEditsRequest, + model: string, +): Promise<{ body: FormData | ReplayableBody; headers?: HeadersInit }> => { const multipart = multipartBody(request, model); - if (multipart !== null) return multipart; - return jsonRequestBody(await serializeOpenAIImagesEditsJsonPayload(request, model)); + if (multipart !== null) return { body: multipart }; + return jsonRequest(await serializeOpenAIImagesEditsJsonPayload(request, model)); }; diff --git a/packages/provider/src/index.ts b/packages/provider/src/index.ts index b9ebaebf45..1b89d2b4b0 100644 --- a/packages/provider/src/index.ts +++ b/packages/provider/src/index.ts @@ -115,7 +115,7 @@ export { directFetcher, dispatchUpstreamFetch, identityWrapUpstreamCall, isRepla export { isAbortError } from './abort.ts'; -export { jsonRequestBody } from './json-request.ts'; +export { jsonRequest, jsonRequestBody } from './json-request.ts'; export { sha256Json, sha256JsonHex } from './json-hash.ts'; export { diff --git a/packages/provider/src/json-request.ts b/packages/provider/src/json-request.ts index 024555e8b0..a26df2df2c 100644 --- a/packages/provider/src/json-request.ts +++ b/packages/provider/src/json-request.ts @@ -26,6 +26,11 @@ const serializedLength = (value: object): number => { return length; }; +export const jsonRequest = (value: object): { body: ReplayableBody; headers: HeadersInit } => ({ + body: jsonRequestBody(value), + headers: { 'content-type': 'application/json' }, +}); + export const jsonRequestBody = (value: object): ReplayableBody => { const snapshot = klona(value); return {