mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 22:35:51 +00:00
refactor: 将 codex provider 转换工具迁移至 @ant/model-provider 包
将纯转换工具(callIds、modelMapping、convertMessages、convertTools) 从 src/services/api/codex/ 迁移到 packages/@ant/model-provider/src/providers/codex/, 与 OpenAI/Gemini/Grok provider 保持一致的代码组织模式。同时修复了 streaming.test.ts 中缺失的 mock 导出(logAntError、context 常量、langfuse 导出)。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { createAssistantMessage, createUserMessage } from '../../../../utils/messages.js'
|
||||
import { anthropicMessagesToCodexInput } from '../convertMessages.js'
|
||||
import { anthropicToolsToCodex } from '../convertTools.js'
|
||||
import { anthropicMessagesToCodexInput, anthropicToolsToCodex } from '@ant/model-provider'
|
||||
|
||||
describe('anthropicMessagesToCodexInput', () => {
|
||||
test('replays assistant tool calls and user tool results in order', async () => {
|
||||
|
||||
@@ -97,33 +97,68 @@ mock.module('../client.js', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module('../convertMessages.js', () => ({
|
||||
anthropicMessagesToCodexInput: () => [],
|
||||
}))
|
||||
|
||||
mock.module('../convertTools.js', () => ({
|
||||
anthropicToolsToCodex: () => [],
|
||||
}))
|
||||
|
||||
mock.module('../model.js', () => ({
|
||||
resolveCodexModel: () => 'gpt-5.4',
|
||||
resolveCodexMaxTokens: () => 4096,
|
||||
}))
|
||||
// Mock only model resolution — conversion functions can use real implementations
|
||||
// since the client mock controls API responses.
|
||||
mock.module('@ant/model-provider', () => {
|
||||
// Import the real module to preserve conversion functions
|
||||
const real = require('@ant/model-provider')
|
||||
return {
|
||||
...real,
|
||||
resolveCodexModel: () => 'gpt-5.4',
|
||||
resolveCodexMaxTokens: () => 4096,
|
||||
}
|
||||
})
|
||||
|
||||
mock.module('../../../../utils/context.js', () => ({
|
||||
MODEL_CONTEXT_WINDOW_DEFAULT: 200_000,
|
||||
COMPACT_MAX_OUTPUT_TOKENS: 20_000,
|
||||
CAPPED_DEFAULT_MAX_TOKENS: 8_000,
|
||||
ESCALATED_MAX_TOKENS: 64_000,
|
||||
is1mContextDisabled: () => false,
|
||||
has1mContext: () => false,
|
||||
modelSupports1M: () => false,
|
||||
getContextWindowForModel: () => 200_000,
|
||||
getSonnet1mExpTreatmentEnabled: () => false,
|
||||
calculateContextPercentages: () => ({}),
|
||||
getModelMaxOutputTokens: () => ({ upperLimit: 4096 }),
|
||||
getMaxThinkingTokensForModel: () => 0,
|
||||
}))
|
||||
|
||||
mock.module('../../../../utils/api.js', () => ({
|
||||
toolToAPISchema: async () => ({}),
|
||||
appendSystemContext: () => {},
|
||||
prependUserContext: () => {},
|
||||
logAPIPrefix: () => {},
|
||||
splitSysPromptPrefix: () => ({ prefix: '', rest: [] }),
|
||||
logContextMetrics: async () => {},
|
||||
normalizeToolInput: (input: any) => input,
|
||||
normalizeToolInputForAPI: (input: any) => input,
|
||||
}))
|
||||
|
||||
mock.module('../../../../utils/debug.js', () => ({
|
||||
mock.module('src/utils/debug.ts', () => ({
|
||||
getMinDebugLogLevel: () => 'debug' as const,
|
||||
isDebugMode: () => false,
|
||||
enableDebugLogging: () => false,
|
||||
getDebugFilter: () => null,
|
||||
isDebugToStdErr: () => false,
|
||||
getDebugFilePath: () => null as string | null,
|
||||
setHasFormattedOutput: () => {},
|
||||
getHasFormattedOutput: () => false,
|
||||
flushDebugLogs: async () => {},
|
||||
logForDebugging: () => {},
|
||||
getDebugLogPath: () => '/tmp/mock-debug.log',
|
||||
logAntError: () => {},
|
||||
}))
|
||||
|
||||
mock.module('../../../../services/langfuse/tracing.js', () => ({
|
||||
createTrace: () => null,
|
||||
recordLLMObservation: () => {},
|
||||
recordToolObservation: () => {},
|
||||
createToolBatchSpan: () => null,
|
||||
endToolBatchSpan: () => {},
|
||||
createSubagentTrace: () => null,
|
||||
createChildSpan: () => null,
|
||||
endTrace: () => {},
|
||||
}))
|
||||
|
||||
mock.module('../../../../services/langfuse/convert.js', () => ({
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { createHash } from 'crypto'
|
||||
|
||||
const MAX_CODEX_CALL_ID_LENGTH = 96
|
||||
|
||||
export function normalizeCodexCallId(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
const sanitized = value
|
||||
.trim()
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/[^A-Za-z0-9._:-]/g, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.slice(0, MAX_CODEX_CALL_ID_LENGTH)
|
||||
|
||||
return sanitized.length > 0 ? sanitized : null
|
||||
}
|
||||
|
||||
export function createCodexFallbackCallId(seed: string): string {
|
||||
const hash = createHash('sha1')
|
||||
.update(seed.length > 0 ? seed : 'codex-call')
|
||||
.digest('hex')
|
||||
.slice(0, 24)
|
||||
|
||||
return `call_${hash}`
|
||||
}
|
||||
|
||||
export function resolveCodexCallId(value: unknown, seed: string): string {
|
||||
return normalizeCodexCallId(value) ?? createCodexFallbackCallId(seed)
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
import type {
|
||||
ResponseFunctionToolCallOutputItem,
|
||||
ResponseInputImage,
|
||||
ResponseInputItem,
|
||||
ResponseInputText,
|
||||
} from 'openai/resources/responses/responses.mjs'
|
||||
import type { Message } from '../../../types/message.js'
|
||||
import {
|
||||
normalizeCodexCallId,
|
||||
resolveCodexCallId,
|
||||
} from './callIds.js'
|
||||
|
||||
type ContentBlock = {
|
||||
type: string
|
||||
text?: string
|
||||
source?: {
|
||||
type?: string
|
||||
data?: string
|
||||
media_type?: string
|
||||
url?: string
|
||||
}
|
||||
}
|
||||
|
||||
type ToolUseLikeBlock = {
|
||||
type: 'tool_use'
|
||||
id: string
|
||||
name: string
|
||||
input: unknown
|
||||
}
|
||||
|
||||
type ToolResultLikeBlock = {
|
||||
type: 'tool_result'
|
||||
tool_use_id: string
|
||||
content?: string | ReadonlyArray<ContentBlock>
|
||||
}
|
||||
|
||||
export type CodexImageConversionOptions = {
|
||||
resolveBase64ImageUrl?: (
|
||||
data: string,
|
||||
mediaType?: string,
|
||||
) => Promise<string | null>
|
||||
}
|
||||
|
||||
type CodexCallIdState = {
|
||||
byOriginalId: Map<string, string>
|
||||
sequence: number
|
||||
}
|
||||
|
||||
function createInputText(text: string): ResponseInputText {
|
||||
return {
|
||||
type: 'input_text',
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
function createInputImage(imageUrl: string): ResponseInputImage {
|
||||
return {
|
||||
type: 'input_image',
|
||||
image_url: imageUrl,
|
||||
detail: 'high',
|
||||
}
|
||||
}
|
||||
|
||||
function getUnsupportedBlockText(type: string): string | null {
|
||||
switch (type) {
|
||||
case 'image':
|
||||
return '[Image omitted: codex gateway currently requires remote image URLs. Configure CODEX_IMGBB_API_KEY to auto-convert local images.]'
|
||||
case 'document':
|
||||
return '[Document omitted: codex gateway does not support document replay.]'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getImageUrl(block: ContentBlock): string | null {
|
||||
const source = block.source
|
||||
if (!source) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (source.type === 'url' && typeof source.url === 'string' && source.url.length > 0) {
|
||||
return source.url
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function resolveImageUrl(
|
||||
block: ContentBlock,
|
||||
options: CodexImageConversionOptions,
|
||||
): Promise<string | null> {
|
||||
const directUrl = getImageUrl(block)
|
||||
if (directUrl) {
|
||||
return directUrl
|
||||
}
|
||||
|
||||
if (block.source?.type !== 'base64') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (options.resolveBase64ImageUrl && typeof block.source.data === 'string') {
|
||||
const uploadedUrl = await options.resolveBase64ImageUrl(
|
||||
block.source.data,
|
||||
block.source.media_type,
|
||||
)
|
||||
if (uploadedUrl) {
|
||||
return uploadedUrl
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function convertBlocksToInputContent(
|
||||
content: ReadonlyArray<ContentBlock>,
|
||||
options: CodexImageConversionOptions,
|
||||
): Promise<Array<ResponseInputText | ResponseInputImage>> {
|
||||
const output: Array<ResponseInputText | ResponseInputImage> = []
|
||||
|
||||
for (const block of content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
output.push(createInputText(block.text))
|
||||
continue
|
||||
}
|
||||
|
||||
if (block.type === 'image') {
|
||||
const imageUrl = await resolveImageUrl(block, options)
|
||||
if (imageUrl) {
|
||||
output.push(createInputImage(imageUrl))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = getUnsupportedBlockText(block.type)
|
||||
if (fallback) {
|
||||
output.push(createInputText(fallback))
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
async function convertToolResultOutput(
|
||||
content: string | ReadonlyArray<ContentBlock> | undefined,
|
||||
options: CodexImageConversionOptions,
|
||||
): Promise<ResponseFunctionToolCallOutputItem['output']> {
|
||||
if (!content) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof content === 'string') {
|
||||
return content
|
||||
}
|
||||
|
||||
const output = await convertBlocksToInputContent(content, options)
|
||||
|
||||
if (output.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (output.length === 1 && output[0].type === 'input_text') {
|
||||
return output[0].text
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
function pushUserMessage(
|
||||
items: ResponseInputItem[],
|
||||
textParts: string[],
|
||||
imageUrls: string[] = [],
|
||||
): void {
|
||||
const text = textParts.join('\n').trim()
|
||||
if (text.length === 0 && imageUrls.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
items.push({
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [
|
||||
...(text.length > 0 ? [createInputText(text)] : []),
|
||||
...imageUrls.map(createInputImage),
|
||||
],
|
||||
} as unknown as ResponseInputItem)
|
||||
}
|
||||
|
||||
function pushAssistantMessage(
|
||||
items: ResponseInputItem[],
|
||||
textParts: string[],
|
||||
): void {
|
||||
const text = textParts.join('\n').trim()
|
||||
if (text.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
items.push({
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text,
|
||||
annotations: [],
|
||||
},
|
||||
],
|
||||
} as unknown as ResponseInputItem)
|
||||
}
|
||||
|
||||
function stringifyToolInput(input: unknown): string {
|
||||
if (typeof input === 'string') {
|
||||
return input
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(input ?? {})
|
||||
} catch {
|
||||
return '{}'
|
||||
}
|
||||
}
|
||||
|
||||
function createCodexCallIdState(): CodexCallIdState {
|
||||
return {
|
||||
byOriginalId: new Map(),
|
||||
sequence: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAssistantCallId(
|
||||
block: ToolUseLikeBlock,
|
||||
state: CodexCallIdState,
|
||||
): string {
|
||||
const originalId = typeof block.id === 'string' ? block.id : ''
|
||||
const seed = `${block.name}:${stringifyToolInput(block.input)}:${state.sequence}`
|
||||
const callId = resolveCodexCallId(originalId, seed)
|
||||
|
||||
if (originalId.length > 0) {
|
||||
state.byOriginalId.set(originalId, callId)
|
||||
}
|
||||
state.sequence += 1
|
||||
|
||||
return callId
|
||||
}
|
||||
|
||||
function resolveToolResultCallId(
|
||||
toolUseId: unknown,
|
||||
state: CodexCallIdState,
|
||||
): string | null {
|
||||
if (typeof toolUseId !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
return state.byOriginalId.get(toolUseId) ?? normalizeCodexCallId(toolUseId)
|
||||
}
|
||||
|
||||
async function convertUserContentToInputItems(
|
||||
items: ResponseInputItem[],
|
||||
content: ReadonlyArray<string | ContentBlock>,
|
||||
options: CodexImageConversionOptions,
|
||||
callIdState: CodexCallIdState,
|
||||
): Promise<void> {
|
||||
const textParts: string[] = []
|
||||
const imageUrls: string[] = []
|
||||
|
||||
for (const block of content) {
|
||||
if (typeof block === 'string') {
|
||||
textParts.push(block)
|
||||
continue
|
||||
}
|
||||
|
||||
if (block.type === 'tool_result') {
|
||||
pushUserMessage(items, textParts, imageUrls)
|
||||
textParts.length = 0
|
||||
imageUrls.length = 0
|
||||
|
||||
const toolResultBlock = block as ToolResultLikeBlock
|
||||
const callId = resolveToolResultCallId(
|
||||
toolResultBlock.tool_use_id,
|
||||
callIdState,
|
||||
)
|
||||
if (!callId) {
|
||||
continue
|
||||
}
|
||||
|
||||
items.push({
|
||||
type: 'function_call_output',
|
||||
call_id: callId,
|
||||
output: await convertToolResultOutput(toolResultBlock.content, options),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (block.type === 'text' && block.text) {
|
||||
textParts.push(block.text)
|
||||
continue
|
||||
}
|
||||
|
||||
if (block.type === 'image') {
|
||||
const imageUrl = await resolveImageUrl(block, options)
|
||||
if (imageUrl) {
|
||||
imageUrls.push(imageUrl)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = getUnsupportedBlockText(block.type)
|
||||
if (fallback) {
|
||||
textParts.push(fallback)
|
||||
}
|
||||
}
|
||||
|
||||
pushUserMessage(items, textParts, imageUrls)
|
||||
}
|
||||
|
||||
function convertAssistantContentToInputItems(
|
||||
items: ResponseInputItem[],
|
||||
content: ReadonlyArray<string | ContentBlock>,
|
||||
callIdState: CodexCallIdState,
|
||||
): void {
|
||||
const textParts: string[] = []
|
||||
|
||||
for (const block of content) {
|
||||
if (typeof block === 'string') {
|
||||
textParts.push(block)
|
||||
continue
|
||||
}
|
||||
|
||||
if (block.type === 'tool_use') {
|
||||
pushAssistantMessage(items, textParts)
|
||||
textParts.length = 0
|
||||
|
||||
const toolUseBlock = block as unknown as ToolUseLikeBlock
|
||||
items.push({
|
||||
type: 'function_call',
|
||||
call_id: resolveAssistantCallId(toolUseBlock, callIdState),
|
||||
name: toolUseBlock.name,
|
||||
arguments: stringifyToolInput(toolUseBlock.input),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (block.type === 'text' && block.text) {
|
||||
textParts.push(block.text)
|
||||
}
|
||||
}
|
||||
|
||||
pushAssistantMessage(items, textParts)
|
||||
}
|
||||
|
||||
export async function anthropicMessagesToCodexInput(
|
||||
messages: Message[],
|
||||
options: CodexImageConversionOptions = {},
|
||||
): Promise<ResponseInputItem[]> {
|
||||
const items: ResponseInputItem[] = []
|
||||
const callIdState = createCodexCallIdState()
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.type !== 'user' && message.type !== 'assistant') {
|
||||
continue
|
||||
}
|
||||
|
||||
const apiMessage = message.message
|
||||
if (!apiMessage?.content) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof apiMessage.content === 'string') {
|
||||
if (message.type === 'user') {
|
||||
pushUserMessage(items, [apiMessage.content])
|
||||
} else {
|
||||
pushAssistantMessage(items, [apiMessage.content])
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (message.type === 'user') {
|
||||
await convertUserContentToInputItems(
|
||||
items,
|
||||
apiMessage.content as ReadonlyArray<string | ContentBlock>,
|
||||
options,
|
||||
callIdState,
|
||||
)
|
||||
} else {
|
||||
convertAssistantContentToInputItems(
|
||||
items,
|
||||
apiMessage.content as ReadonlyArray<string | ContentBlock>,
|
||||
callIdState,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||
import type { Tool as CodexTool } from 'openai/resources/responses/responses.mjs'
|
||||
|
||||
function isClientFunctionTool(
|
||||
tool: BetaToolUnion,
|
||||
): tool is BetaToolUnion & {
|
||||
name: string
|
||||
description?: string
|
||||
input_schema?: { [key: string]: unknown }
|
||||
strict?: boolean
|
||||
defer_loading?: boolean
|
||||
} {
|
||||
const value = tool as unknown as Record<string, unknown>
|
||||
return typeof value.name === 'string'
|
||||
}
|
||||
|
||||
export function anthropicToolsToCodex(
|
||||
tools: BetaToolUnion[],
|
||||
): CodexTool[] {
|
||||
return tools.flatMap(tool => {
|
||||
const value = tool as unknown as Record<string, unknown>
|
||||
if (
|
||||
value.type === 'advisor_20260301' ||
|
||||
value.type === 'computer_20250124' ||
|
||||
!isClientFunctionTool(tool)
|
||||
) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [{
|
||||
type: 'function',
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.input_schema ?? {},
|
||||
strict: tool.strict ?? null,
|
||||
...(tool.defer_loading && { defer_loading: true }),
|
||||
}]
|
||||
})
|
||||
}
|
||||
@@ -27,15 +27,18 @@ import {
|
||||
convertOutputToLangfuse,
|
||||
convertToolsToLangfuse,
|
||||
} from '../../../services/langfuse/convert.js'
|
||||
import { anthropicMessagesToCodexInput } from './convertMessages.js'
|
||||
import { anthropicToolsToCodex } from './convertTools.js'
|
||||
import {
|
||||
anthropicMessagesToCodexInput,
|
||||
anthropicToolsToCodex,
|
||||
resolveCodexMaxTokens,
|
||||
resolveCodexModel,
|
||||
} from '@ant/model-provider'
|
||||
import { getCodexClient } from './client.js'
|
||||
import { uploadCodexBase64Image } from './imageUpload.js'
|
||||
import {
|
||||
getCodexConfigurationError,
|
||||
normalizeCodexError,
|
||||
} from './errors.js'
|
||||
import { resolveCodexMaxTokens, resolveCodexModel } from './model.js'
|
||||
import { sanitizeCodexRequest } from './preflight.js'
|
||||
import {
|
||||
addCodexUsage,
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null {
|
||||
if (/haiku/i.test(model)) return 'haiku'
|
||||
if (/opus/i.test(model)) return 'opus'
|
||||
if (/sonnet/i.test(model)) return 'sonnet'
|
||||
return null
|
||||
}
|
||||
|
||||
export function resolveCodexModel(model: string): string {
|
||||
if (process.env.CODEX_MODEL) {
|
||||
return process.env.CODEX_MODEL
|
||||
}
|
||||
|
||||
const cleanModel = model.replace(/\[1m\]$/, '')
|
||||
const family = getModelFamily(cleanModel)
|
||||
if (family) {
|
||||
const familyOverride = process.env[`CODEX_DEFAULT_${family.toUpperCase()}_MODEL`]
|
||||
if (familyOverride) {
|
||||
return familyOverride
|
||||
}
|
||||
}
|
||||
|
||||
return cleanModel
|
||||
}
|
||||
|
||||
export function resolveCodexMaxTokens(
|
||||
upperLimit: number,
|
||||
maxOutputTokensOverride?: number,
|
||||
): number {
|
||||
return (
|
||||
maxOutputTokensOverride ??
|
||||
(process.env.CODEX_MAX_TOKENS
|
||||
? parseInt(process.env.CODEX_MAX_TOKENS, 10) || undefined
|
||||
: undefined) ??
|
||||
(process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
||||
? parseInt(process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS, 10) || undefined
|
||||
: undefined) ??
|
||||
upperLimit
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
ResponseInputItem,
|
||||
Tool,
|
||||
} from 'openai/resources/responses/responses.mjs'
|
||||
import { normalizeCodexCallId } from './callIds.js'
|
||||
import { normalizeCodexCallId } from '@ant/model-provider'
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
normalizeContentFromAPI,
|
||||
} from '../../../utils/messages.js'
|
||||
import { getCodexClient } from './client.js'
|
||||
import { resolveCodexCallId } from './callIds.js'
|
||||
import { resolveCodexCallId } from '@ant/model-provider'
|
||||
import { toStreamingCodexRequest } from './preflight.js'
|
||||
|
||||
export type RawAssistantBlock =
|
||||
|
||||
Reference in New Issue
Block a user