Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@
// content type. Only `role: 'assistant'` is rewritten; user/system/developer
// messages keep `input_text` because that IS the correct type on those roles.

import type { OpenAIResponsesInputContent, CanonicalOpenAIResponsesInputItem } from '@floway-dev/protocols/openai-responses';
import type { OpenAIResponsesAssistantInputContent, CanonicalOpenAIResponsesInputItem } from '@floway-dev/protocols/openai-responses';

const normalizeAssistantContentBlocks = (content: string | OpenAIResponsesInputContent[]): string | OpenAIResponsesInputContent[] => {
const normalizeAssistantContentBlocks = (content: string | OpenAIResponsesAssistantInputContent[]): string | OpenAIResponsesAssistantInputContent[] => {
if (typeof content === 'string') return content;
let mutated = false;
const next = content.map(block => {
Expand All @@ -28,7 +28,7 @@ const normalizeAssistantContentBlocks = (content: string | OpenAIResponsesInputC
const normalizeItem = (item: CanonicalOpenAIResponsesInputItem): CanonicalOpenAIResponsesInputItem => {
if (item.type !== 'message' || item.role !== 'assistant') return item;
const next = normalizeAssistantContentBlocks(item.content);
return next === item.content ? item : { ...item, content: next };
return next === item.content ? item : { ...item, content: next } as CanonicalOpenAIResponsesInputItem;
};

export const normalizeAssistantInputText = (input: CanonicalOpenAIResponsesInputItem[]): CanonicalOpenAIResponsesInputItem[] => {
Expand Down
1 change: 0 additions & 1 deletion packages/protocols/src/openai-chat-completions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,6 @@ export interface OpenAIChatCompletionsFilePart { type: 'file'; file: { file_data
export interface OpenAIChatCompletionsRefusalPart { type: 'refusal'; refusal: string }
export type OpenAIChatCompletionsUserContentPart = OpenAIChatCompletionsTextPart | OpenAIChatCompletionsImagePart | OpenAIChatCompletionsInputAudioPart | OpenAIChatCompletionsFilePart;
export type OpenAIChatCompletionsAssistantContentPart = OpenAIChatCompletionsTextPart | OpenAIChatCompletionsRefusalPart;
export type OpenAIChatCompletionsContentPart = OpenAIChatCompletionsUserContentPart | OpenAIChatCompletionsRefusalPart;

// Provider extension types belong only at the assistant boundary.
export interface OpenAIChatCompletionsAssistantExtensions {
Expand Down
13 changes: 9 additions & 4 deletions packages/protocols/src/openai-responses/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,18 +140,21 @@ export type CanonicalOpenAIResponsesInputItem =

export type OpenAIResponsesMessagePhase = 'commentary' | 'final_answer' | (string & {}) | null;

export interface OpenAIResponsesInputMessageEx {
interface OpenAIResponsesInputMessageFields {
type: 'message';
id?: string;
status?: string;
role: 'user' | 'assistant' | 'system' | 'developer';
content: string | OpenAIResponsesInputContent[];
phase?: OpenAIResponsesMessagePhase;
// Codex marks base-instruction fragments on ordinary developer messages.
// https://github.com/openai/codex/blob/0462dcc062b822bb8fff16cc31ce6eeab69823b9/codex-rs/core/tests/suite/responses_lite.rs#L136-L155
internal_chat_message_metadata_passthrough?: Record<string, unknown>;
}

export type OpenAIResponsesInputMessageEx = OpenAIResponsesInputMessageFields & (
| { role: 'user' | 'system' | 'developer'; content: string | OpenAIResponsesInputContent[] }
| { role: 'assistant'; content: string | OpenAIResponsesAssistantInputContent[] }
);

// The OpenAI Responses request schema's EasyInputMessage makes the constant
// `type: "message"` discriminator optional. Wire-facing payloads accept that
// shorthand; gateway and translator boundaries normalize it before internal
Expand All @@ -178,7 +181,9 @@ export type CanonicalOpenAIResponsesPayload = Omit<OpenAIResponsesPayloadEx, 'in
input: CanonicalOpenAIResponsesInputItem[];
};

export type OpenAIResponsesInputContent = CanonicalOpenAIResponsesText | OpenAIResponsesInputImage | OpenAIResponsesInputFile | OpenAIResponsesOutputRefusal;
export type OpenAIResponsesInputContent = CanonicalOpenAIResponsesText | OpenAIResponsesInputImage | OpenAIResponsesInputFile;

export type OpenAIResponsesAssistantInputContent = OpenAIResponsesInputContent | OpenAIResponsesOutputRefusal;

// Explicit content breakpoints inherit their lifetime from
// `prompt_cache_options.ttl`. The mode stays open-string for forward
Expand Down
4 changes: 3 additions & 1 deletion packages/provider-codex/src/responses-lite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,9 @@ const removeInputImageDetails = <T extends { type: string }>(parts: T[]): T[] =>
const removeLiteImageDetail = (item: CanonicalOpenAIResponsesInputItem): CanonicalOpenAIResponsesInputItem => {
if (item.type === 'message' && Array.isArray(item.content)) {
const content = removeInputImageDetails(item.content);
return content === item.content ? item : { ...item, content };
// The role conditions `content`'s shape, so an assistant turn keeps its
// refusal arm while a client role cannot gain one here.
return content === item.content ? item : { ...item, content } as CanonicalOpenAIResponsesInputItem;
}
if ((item.type === 'function_call_output' || item.type === 'custom_tool_call_output') && Array.isArray(item.output)) {
const output = removeInputImageDetails(item.output);
Expand Down
14 changes: 14 additions & 0 deletions packages/provider-copilot/__tests__/compaction_test.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 7 additions & 10 deletions packages/provider-copilot/src/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
// https://github.com/openai/codex/blob/3d805abdf09093bfa806f359a5adc6514766c420/codex-rs/core/src/compact_remote_v2.rs#L439-L501
// https://github.com/openai/codex/blob/3d805abdf09093bfa806f359a5adc6514766c420/codex-rs/utils/string/src/truncate.rs#L71-L74

import { isOpenAIResponsesCompactionItem, createRandomOpenAIResponsesItemId, type OpenAIResponsesCompactionResultEx, type OpenAIResponsesCompactionTriggerItem, type OpenAIResponsesInputContent, type CanonicalOpenAIResponsesInputItem, type OpenAIResponsesInputMessageEx, type OpenAIResponsesOutputItemEx, type OpenAIResponsesResultEx } from '@floway-dev/protocols/openai-responses';
import { isOpenAIResponsesCompactionItem, createRandomOpenAIResponsesItemId, type OpenAIResponsesCompactionResultEx, type OpenAIResponsesCompactionTriggerItem, type OpenAIResponsesAssistantInputContent, type CanonicalOpenAIResponsesInputItem, type CanonicalOpenAIResponsesText, type OpenAIResponsesInputMessageEx, type OpenAIResponsesOutputItemEx, type OpenAIResponsesResultEx } from '@floway-dev/protocols/openai-responses';

export const COMPACTION_TRIGGER: OpenAIResponsesCompactionTriggerItem = { type: 'compaction_trigger' };

Expand All @@ -26,11 +26,7 @@ const RETAINED_BUDGET_TOKENS = 64_000;
const APPROX_BYTES_PER_TOKEN = 4;
const encoder = new TextEncoder();

// Native compact echoes every text part — including assistant `output_text` —
// as `input_text` so the client can resend `output` verbatim as next-turn
// `input`. Normalize unconditionally; non-text content passes through and costs
// 0 tokens against the retained budget.
const normalizeContent = (content: OpenAIResponsesInputMessageEx['content']): OpenAIResponsesInputContent[] => {
const normalizeContent = <Content extends OpenAIResponsesAssistantInputContent>(content: string | Content[]): (Content | CanonicalOpenAIResponsesText)[] => {
if (typeof content === 'string') return [{ type: 'input_text', text: content }];
return content.map(part => (part.type === 'output_text' ? { ...part, type: 'input_text' } : part));
};
Expand All @@ -54,8 +50,10 @@ export const compactionResponse = (input: CanonicalOpenAIResponsesInputItem[], g
const item = input[i];
if (!isRetainedMessage(item)) continue;

const content = normalizeContent(item.content);
const tokens = content.reduce((sum, part) =>
const message = item.role === 'assistant'
? { role: item.role, content: normalizeContent(item.content) }
: { role: item.role, content: normalizeContent(item.content) };
const tokens = message.content.reduce((sum, part) =>
part.type === 'input_text'
? sum + Math.ceil(encoder.encode(part.text).length / APPROX_BYTES_PER_TOKEN)
: sum, 0);
Expand All @@ -66,8 +64,7 @@ export const compactionResponse = (input: CanonicalOpenAIResponsesInputItem[], g
type: 'message',
id: createRandomOpenAIResponsesItemId('message'),
status: item.status ?? 'completed',
role: item.role,
content,
...message,
...(item.phase !== undefined ? { phase: item.phase } : {}),
});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { memoizedDataUrlCompressor } from '../image-compression.ts';
import { targetSizeForOpenAIResponsesChat } from '../image-size.ts';
import type { OpenAIChatCompletionsBoundaryCtx, CopilotOpenAIChatCompletionsBoundaryInterceptor } from './types.ts';
import type { OpenAIChatCompletionsContentPart, OpenAIChatCompletionsMessage } from '@floway-dev/protocols/openai-chat-completions';
import type { OpenAIChatCompletionsUserContentPart, OpenAIChatCompletionsMessage } from '@floway-dev/protocols/openai-chat-completions';
import { isBase64ImageDataUrl } from '@floway-dev/provider';

type OpenAIChatCompletionsImagePart = Extract<OpenAIChatCompletionsContentPart, { type: 'image_url' }>;
type OpenAIChatCompletionsImagePart = Extract<OpenAIChatCompletionsUserContentPart, { type: 'image_url' }>;

const compressInlineImages = async (ctx: OpenAIChatCompletionsBoundaryCtx): Promise<void> => {
const targets: OpenAIChatCompletionsImagePart[] = [];
Expand All @@ -24,7 +24,7 @@ const compressInlineImages = async (ctx: OpenAIChatCompletionsBoundaryCtx): Prom
compressedUrls.set(target, await compress(target.image_url.url));
}),
);
const hasCompressedImage = (part: OpenAIChatCompletionsContentPart): part is OpenAIChatCompletionsImagePart =>
const hasCompressedImage = (part: OpenAIChatCompletionsUserContentPart): part is OpenAIChatCompletionsImagePart =>
part.type === 'image_url' && compressedUrls.has(part);
const rewriteImage = (part: OpenAIChatCompletionsImagePart): OpenAIChatCompletionsImagePart => {
const url = compressedUrls.get(part);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { memoizedDataUrlCompressor } from '../image-compression.ts';
import { targetSizeForOpenAIResponsesChat } from '../image-size.ts';
import type { OpenAIResponsesBoundaryCtx } from './types.ts';
import type { OpenAIResponsesInputContent, OpenAIResponsesInputImage, OpenAIResponsesToolOutputContent } from '@floway-dev/protocols/openai-responses';
import type { CanonicalOpenAIResponsesInputItem, OpenAIResponsesInputContent, OpenAIResponsesInputImage, OpenAIResponsesOutputRefusal, OpenAIResponsesToolOutputContent } from '@floway-dev/protocols/openai-responses';
import { isBase64ImageDataUrl } from '@floway-dev/provider';

// A single client request can reach this boundary more than once — a
Expand Down Expand Up @@ -38,7 +38,7 @@ const compressInlineImages = async (ctx: OpenAIResponsesBoundaryCtx): Promise<vo
compressedUrls.set(target.part, await compress(target.imageUrl));
}),
);
const hasCompressedImage = (part: OpenAIResponsesInputContent): part is CompressibleImagePart =>
const hasCompressedImage = (part: OpenAIResponsesInputContent | OpenAIResponsesOutputRefusal): part is CompressibleImagePart =>
part.type === 'input_image' && compressedUrls.has(part as CompressibleImagePart);
const rewriteImage = (part: CompressibleImagePart): CompressibleImagePart => {
const imageUrl = compressedUrls.get(part);
Expand All @@ -51,15 +51,15 @@ const compressInlineImages = async (ctx: OpenAIResponsesBoundaryCtx): Promise<vo
});
return rewritten;
};
const rewriteParts = <TPart extends OpenAIResponsesInputContent | OpenAIResponsesToolOutputContent>(parts: TPart[]): TPart[] =>
const rewriteParts = <TPart extends OpenAIResponsesInputContent | OpenAIResponsesOutputRefusal | OpenAIResponsesToolOutputContent>(parts: TPart[]): TPart[] =>
parts.map(part => hasCompressedImage(part) ? rewriteImage(part) as TPart : part);

ctx.payload = {
...ctx.payload,
input: ctx.payload.input.map(item => {
if (item.type === 'message' && Array.isArray(item.content)) {
return item.content.some(hasCompressedImage)
? { ...item, content: rewriteParts(item.content) }
? { ...item, content: rewriteParts(item.content) } as CanonicalOpenAIResponsesInputItem
: item;
}
if ((item.type === 'function_call_output' || item.type === 'custom_tool_call_output') && Array.isArray(item.output)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import { TranslatorInputError } from '../../../translator-input-error.ts';
import type { OpenAIChatCompletionsContentPart } from '@floway-dev/protocols/openai-chat-completions';
import type { OpenAIChatCompletionsUserContentPart } from '@floway-dev/protocols/openai-chat-completions';
import type { OpenAIResponsesInputContent } from '@floway-dev/protocols/openai-responses';

// OpenAI Chat Completions and OpenAI Responses text arrays are transport fragments of one message, not
// paragraph blocks. Preserve the existing no-separator flattening.
const contentPartText = (part: OpenAIChatCompletionsContentPart | OpenAIResponsesInputContent): string | null => (part.type === 'text' || part.type === 'input_text' || part.type === 'output_text' ? part.text : null);
const contentPartText = (part: OpenAIChatCompletionsUserContentPart | OpenAIResponsesInputContent): string | null => (part.type === 'text' || part.type === 'input_text' || part.type === 'output_text' ? part.text : null);

const contentPartsToText = (parts: readonly (OpenAIChatCompletionsContentPart | OpenAIResponsesInputContent)[]): string =>
const contentPartsToText = (parts: readonly (OpenAIChatCompletionsUserContentPart | OpenAIResponsesInputContent)[]): string =>
parts
.map(contentPartText)
.filter((text): text is string => text !== null)
.join('');

export const openaiChatCompletionsContentToText = (content: string | OpenAIChatCompletionsContentPart[] | null | undefined): string => (typeof content === 'string' ? content : Array.isArray(content) ? contentPartsToText(content) : '');
export const openaiChatCompletionsContentToText = (content: string | OpenAIChatCompletionsUserContentPart[] | null | undefined): string => (typeof content === 'string' ? content : Array.isArray(content) ? contentPartsToText(content) : '');

export const openaiChatCompletionsContentToOpenAIResponsesInputContent = (content: string | OpenAIChatCompletionsContentPart[] | null | undefined): string | OpenAIResponsesInputContent[] => {
export const openaiChatCompletionsContentToOpenAIResponsesInputContent = (content: string | OpenAIChatCompletionsUserContentPart[] | null | undefined): string | OpenAIResponsesInputContent[] => {
if (typeof content === 'string') return content;
if (!Array.isArray(content) || content.length === 0) return '';

Expand All @@ -23,8 +23,6 @@ export const openaiChatCompletionsContentToOpenAIResponsesInputContent = (conten
switch (part.type) {
case 'text':
return { type: 'input_text', text: part.text };
case 'refusal':
return { type: 'refusal', refusal: part.refusal };
case 'input_audio':
throw new TranslatorInputError('Cannot translate input_audio content to OpenAI Responses.');
case 'file':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { SidecarCodec } from '../../../types.ts';
import { partitionOpenAIChatCompletionsTurns } from '../as-source/conversation-turns.ts';
import { collectOpenAIChatCompletionsReplayContents, hasOpenAIChatCompletionsReasoning, findOpenAIChatCompletionsSidecarCarriers, verifyOpenAIChatCompletionsTurnDigest } from '../as-source/round-trip.ts';
import type { OpenAIChatCompletionsAssistantMessage, OpenAIChatCompletionsMessage, OpenAIChatCompletionsPayload, OpenAIChatCompletionsTool } from '@floway-dev/protocols/openai-chat-completions';
import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesInputContent, CanonicalOpenAIResponsesInputItem, OpenAIResponsesTool, OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-responses';
import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesAssistantInputContent, CanonicalOpenAIResponsesInputItem, OpenAIResponsesTool, OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-responses';

const translateChatTools = (tools?: OpenAIChatCompletionsTool[] | null): OpenAIResponsesTool[] =>
tools?.length
Expand Down Expand Up @@ -36,8 +36,8 @@ const translateChatToolChoice = (choice: NonNullable<OpenAIChatCompletionsPayloa
};
};

const translateAssistantContent = (message: OpenAIChatCompletionsAssistantMessage): OpenAIResponsesInputContent[] => {
const content: OpenAIResponsesInputContent[] = [];
const translateAssistantContent = (message: OpenAIChatCompletionsAssistantMessage): OpenAIResponsesAssistantInputContent[] => {
const content: OpenAIResponsesAssistantInputContent[] = [];
let hasRefusalPart = false;

if (typeof message.content === 'string') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,6 @@ const translateToolOutput = async (output: string | OpenAIResponsesInputContent[
blocks.push(image);
} else if (part.type === 'input_file') {
throw new TranslatorInputError('Cannot translate input_file tool output to Anthropic Messages.');
} else if (part.type === 'refusal') {
throw new TranslatorInputError('Cannot translate refusal content in a tool output to Anthropic Messages.');
} else {
blocks.push({ type: 'text', text: part.text });
}
Expand Down
Loading
Loading