Files
orcs-code/src/services/api/codexShim.ts
Zartris f4ac709fa6 fix: report cache reads in streaming and correct cost calculation (#577)
* fix: report cache reads in streaming and correct cost calculation

Fix two bugs in how the OpenAI-to-Anthropic shim handles cached tokens:

1. codexShim: streaming message_delta missing cache_read_input_tokens
   The codexStreamToAnthropic() function builds the final message_delta
   usage object inline (not through makeUsage()), and only included
   input_tokens and output_tokens. cache_read_input_tokens was always 0,
   so /cost never showed cache reads for Responses API models (GPT-5+).

   Also fix makeUsage() to read input_tokens_details.cached_tokens and
   prompt_tokens_details.cached_tokens for the non-streaming path.

2. Both shims: cost double-counting from convention mismatch
   OpenAI includes cached tokens in input_tokens/prompt_tokens (i.e.,
   input_tokens = uncached + cached). Anthropic treats input_tokens as
   uncached only. The cost formula was:
     cost = input_tokens * inputRate + cache_read * cacheRate
   This double-counts cached tokens. Fix by subtracting cached from
   input during the conversion:
     input_tokens = prompt_tokens - cached_tokens

   In practice this was inflating reported costs by ~2x for sessions
   with high cache hit rates (which is most sessions, since Copilot
   auto-caches server-side).

Fixes #515

* fix: omit zero cache read/write fields from /cost output

Only show "cache read" and "cache write" in /cost per-model usage when
the value is > 0. Providers like GitHub Copilot never report
cache_creation_input_tokens (the server manages its own cache), so
showing "0 cache write" on every line is misleading — it implies caching
is not working when it actually is.

Before:
  claude-haiku:  2.6k input, 151 output, 39.8k cache read, 0 cache write ($0.04)

After:
  claude-haiku:  2.6k input, 151 output, 39.8k cache read ($0.04)

---------

Co-authored-by: Zartris <14197299+Zartris@users.noreply.github.com>
2026-04-10 23:40:42 +08:00

961 lines
26 KiB
TypeScript

import { APIError } from '@anthropic-ai/sdk'
import type {
ResolvedCodexCredentials,
ResolvedProviderRequest,
} from './providerConfig.js'
import { sanitizeSchemaForOpenAICompat } from './openaiSchemaSanitizer.js'
import {
looksLikeLeakedReasoningPrefix,
shouldBufferPotentialReasoningPrefix,
stripLeakedReasoningPreamble,
} from './reasoningLeakSanitizer.js'
export interface AnthropicUsage {
input_tokens: number
output_tokens: number
cache_creation_input_tokens: number
cache_read_input_tokens: number
}
export interface AnthropicStreamEvent {
type: string
message?: Record<string, unknown>
index?: number
content_block?: Record<string, unknown>
delta?: Record<string, unknown>
usage?: Partial<AnthropicUsage>
}
export interface ShimCreateParams {
model: string
messages: Array<Record<string, unknown>>
system?: unknown
tools?: Array<Record<string, unknown>>
max_tokens: number
stream?: boolean
temperature?: number
top_p?: number
tool_choice?: unknown
metadata?: unknown
[key: string]: unknown
}
type ResponsesInputPart =
| { type: 'input_text'; text: string }
| { type: 'output_text'; text: string }
| { type: 'input_image'; image_url: string }
type ResponsesInputItem =
| {
type: 'message'
role: 'user' | 'assistant'
content: ResponsesInputPart[]
}
| {
type: 'function_call'
id: string
call_id: string
name: string
arguments: string
}
| {
type: 'function_call_output'
call_id: string
output: string
}
type ResponsesTool = {
type: 'function'
name: string
description: string
parameters: Record<string, unknown>
strict?: boolean
}
type CodexSseEvent = {
event: string
data: Record<string, any>
}
function makeUsage(usage?: {
input_tokens?: number
output_tokens?: number
input_tokens_details?: { cached_tokens?: number }
prompt_tokens_details?: { cached_tokens?: number }
}): AnthropicUsage {
return {
input_tokens: usage?.input_tokens ?? 0,
output_tokens: usage?.output_tokens ?? 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens:
usage?.input_tokens_details?.cached_tokens ??
usage?.prompt_tokens_details?.cached_tokens ??
0,
}
}
function makeMessageId(): string {
return `msg_${crypto.randomUUID().replace(/-/g, '')}`
}
function normalizeToolUseId(toolUseId: string | undefined): {
id: string
callId: string
} {
const value = (toolUseId || '').trim()
if (!value) {
return {
id: 'fc_unknown',
callId: 'call_unknown',
}
}
if (value.startsWith('call_')) {
return {
id: `fc_${value.slice('call_'.length)}`,
callId: value,
}
}
if (value.startsWith('fc_')) {
return {
id: value,
callId: `call_${value.slice('fc_'.length)}`,
}
}
return {
id: `fc_${value}`,
callId: value,
}
}
function convertSystemPrompt(system: unknown): string {
if (!system) return ''
if (typeof system === 'string') return system
if (Array.isArray(system)) {
return system
.map((block: { type?: string; text?: string }) =>
block.type === 'text' ? (block.text ?? '') : '',
)
.join('\n\n')
}
return String(system)
}
function convertToolResultToText(content: unknown): string {
if (typeof content === 'string') return content
if (!Array.isArray(content)) return JSON.stringify(content ?? '')
const chunks: string[] = []
for (const block of content) {
if (block?.type === 'text' && typeof block.text === 'string') {
chunks.push(block.text)
continue
}
if (block?.type === 'image') {
const src = block.source
if (src?.type === 'url' && src.url) {
chunks.push(`[Image](${src.url})`)
}
continue
}
if (typeof block?.text === 'string') {
chunks.push(block.text)
}
}
return chunks.join('\n')
}
function convertContentBlocksToResponsesParts(
content: unknown,
role: 'user' | 'assistant',
): ResponsesInputPart[] {
const textType = role === 'assistant' ? 'output_text' : 'input_text'
if (typeof content === 'string') {
return [{ type: textType, text: content }]
}
if (!Array.isArray(content)) {
return [{ type: textType, text: String(content ?? '') }]
}
const parts: ResponsesInputPart[] = []
for (const block of content) {
switch (block?.type) {
case 'text':
parts.push({ type: textType, text: block.text ?? '' })
break
case 'image': {
if (role === 'assistant') break
const source = block.source
if (source?.type === 'base64') {
parts.push({
type: 'input_image',
image_url: `data:${source.media_type};base64,${source.data}`,
})
} else if (source?.type === 'url' && source.url) {
parts.push({
type: 'input_image',
image_url: source.url,
})
}
break
}
case 'thinking':
if (block.thinking) {
parts.push({
type: textType,
text: `<thinking>${block.thinking}</thinking>`,
})
}
break
case 'tool_use':
case 'tool_result':
break
default:
if (typeof block?.text === 'string') {
parts.push({ type: textType, text: block.text })
}
}
}
return parts
}
export function convertAnthropicMessagesToResponsesInput(
messages: Array<{ role?: string; message?: { role?: string; content?: unknown }; content?: unknown }>,
): ResponsesInputItem[] {
const items: ResponsesInputItem[] = []
for (const message of messages) {
const inner = message.message ?? message
const role = (inner as { role?: string }).role ?? message.role
const content = (inner as { content?: unknown }).content
if (role === 'user') {
if (Array.isArray(content)) {
const toolResults = content.filter(
(block: { type?: string }) => block.type === 'tool_result',
)
const otherContent = content.filter(
(block: { type?: string }) => block.type !== 'tool_result',
)
for (const toolResult of toolResults) {
const { callId } = normalizeToolUseId(toolResult.tool_use_id)
items.push({
type: 'function_call_output',
call_id: callId,
output: (() => {
const out = convertToolResultToText(toolResult.content)
return toolResult.is_error ? `Error: ${out}` : out
})(),
})
}
const parts = convertContentBlocksToResponsesParts(otherContent, 'user')
if (parts.length > 0) {
items.push({
type: 'message',
role: 'user',
content: parts,
})
}
continue
}
items.push({
type: 'message',
role: 'user',
content: convertContentBlocksToResponsesParts(content, 'user'),
})
continue
}
if (role === 'assistant') {
const textBlocks = Array.isArray(content)
? content.filter((block: { type?: string }) =>
block.type !== 'tool_use' && block.type !== 'thinking')
: content
const parts = convertContentBlocksToResponsesParts(textBlocks, 'assistant')
if (parts.length > 0) {
items.push({
type: 'message',
role: 'assistant',
content: parts,
})
}
if (Array.isArray(content)) {
for (const toolUse of content.filter(
(block: { type?: string }) => block.type === 'tool_use',
)) {
const normalized = normalizeToolUseId(toolUse.id)
items.push({
type: 'function_call',
id: normalized.id,
call_id: normalized.callId,
name: toolUse.name ?? 'tool',
arguments:
typeof toolUse.input === 'string'
? toolUse.input
: JSON.stringify(toolUse.input ?? {}),
})
}
}
}
}
return items.filter(item =>
item.type !== 'message' || item.content.length > 0,
)
}
/**
* Recursively enforces Codex strict-mode constraints on a JSON schema:
* - Every `object` type gets `additionalProperties: false`
* - All property keys are listed in `required`
* - Nested schemas (properties, items, anyOf/oneOf/allOf) are processed too
*/
function enforceStrictSchema(schema: unknown): Record<string, unknown> {
const record = sanitizeSchemaForOpenAICompat(schema)
// Codex Responses rejects JSON Schema's standard `uri` string format.
// Keep URL validation in the tool layer and send a plain string here.
if (record.format === 'uri') {
delete record.format
}
if (record.type === 'object') {
// OpenAI structured outputs completely forbid dynamic additionalProperties.
// They must be set to false unconditionally.
record.additionalProperties = false
if (
record.properties &&
typeof record.properties === 'object' &&
!Array.isArray(record.properties)
) {
const props = record.properties as Record<string, unknown>
const allKeys = Object.keys(props)
const enforcedProps: Record<string, unknown> = {}
for (const [key, value] of Object.entries(props)) {
const strictValue = enforceStrictSchema(value)
// If the resulting schema is an empty object (no properties), OpenAI structured outputs will likely
// strip it silently and then complain about a 'required' mismatch if it remains in the required list.
// E.g. z.record() objects (like AskUserQuestion.answers) lose their schema due to additionalProperties
// restrictions. We can safely drop these from the schema sent to the LLM.
if (
strictValue &&
typeof strictValue === 'object' &&
strictValue.type === 'object' &&
strictValue.additionalProperties === false &&
(!strictValue.properties || Object.keys(strictValue.properties).length === 0)
) {
continue
}
enforcedProps[key] = strictValue
}
record.properties = enforcedProps
record.required = Object.keys(enforcedProps)
} else {
// No properties — empty required array
record.required = []
}
}
// Recurse into array items
if ('items' in record) {
if (Array.isArray(record.items)) {
record.items = (record.items as unknown[]).map(item => enforceStrictSchema(item))
} else {
record.items = enforceStrictSchema(record.items)
}
}
// Recurse into combinators
for (const key of ['anyOf', 'oneOf', 'allOf'] as const) {
if (key in record && Array.isArray(record[key])) {
record[key] = (record[key] as unknown[]).map(item => enforceStrictSchema(item))
}
}
return record
}
export function convertToolsToResponsesTools(
tools: Array<{ name?: string; description?: string; input_schema?: Record<string, unknown> }>,
): ResponsesTool[] {
return tools
.filter(tool => tool.name && tool.name !== 'ToolSearchTool')
.map(tool => {
const rawParameters = tool.input_schema ?? { type: 'object', properties: {} }
// Codex requires strict schemas: all properties must be required
const parameters = enforceStrictSchema(rawParameters)
return {
type: 'function',
name: tool.name ?? 'tool',
description: tool.description ?? '',
parameters,
strict: true,
}
})
}
function isStrictResponsesSchema(schema: unknown): boolean {
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
return true
}
const record = schema as Record<string, unknown>
const type = record.type
if (type === 'object') {
const properties =
record.properties && typeof record.properties === 'object' && !Array.isArray(record.properties)
? (record.properties as Record<string, unknown>)
: {}
const propertyKeys = Object.keys(properties)
const required = Array.isArray(record.required)
? record.required.filter((value): value is string => typeof value === 'string')
: null
if (propertyKeys.length > 0) {
if (!required) return false
const requiredSet = new Set(required)
for (const key of propertyKeys) {
if (!requiredSet.has(key)) {
return false
}
}
}
for (const child of Object.values(properties)) {
if (!isStrictResponsesSchema(child)) {
return false
}
}
}
const combinators = ['anyOf', 'oneOf', 'allOf'] as const
for (const key of combinators) {
if (key in record) {
const value = record[key]
if (!Array.isArray(value) || value.some(item => !isStrictResponsesSchema(item))) {
return false
}
}
}
if ('items' in record) {
const items = record.items
if (Array.isArray(items)) {
return items.every(item => isStrictResponsesSchema(item))
}
return isStrictResponsesSchema(items)
}
return true
}
function convertToolChoice(toolChoice: unknown): unknown {
const choice = toolChoice as { type?: string; name?: string } | undefined
if (!choice?.type) return undefined
if (choice.type === 'auto') return 'auto'
if (choice.type === 'any') return 'required'
if (choice.type === 'none') return 'none'
if (choice.type === 'tool' && choice.name) {
return {
type: 'function',
name: choice.name,
}
}
return undefined
}
export async function performCodexRequest(options: {
request: ResolvedProviderRequest
credentials: ResolvedCodexCredentials
params: ShimCreateParams
defaultHeaders: Record<string, string>
signal?: AbortSignal
}): Promise<Response> {
const input = convertAnthropicMessagesToResponsesInput(
options.params.messages as Array<{
role?: string
message?: { role?: string; content?: unknown }
content?: unknown
}>,
)
const body: Record<string, unknown> = {
model: options.request.resolvedModel,
input: input.length > 0
? input
: [
{
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: '' }],
},
],
store: false,
stream: true,
}
const instructions = convertSystemPrompt(options.params.system)
if (instructions) {
body.instructions = instructions
}
const toolChoice = convertToolChoice(options.params.tool_choice)
if (toolChoice) {
body.tool_choice = toolChoice
}
if (options.params.tools && options.params.tools.length > 0) {
const convertedTools = convertToolsToResponsesTools(
options.params.tools as Array<{
name?: string
description?: string
input_schema?: Record<string, unknown>
}>,
)
if (convertedTools.length > 0) {
body.tools = convertedTools
body.parallel_tool_calls = true
body.tool_choice ??= 'auto'
}
}
if (options.request.reasoning) {
body.reasoning = options.request.reasoning
}
const isTargetModel =
options.request.resolvedModel?.toLowerCase().includes('gpt') ||
options.request.resolvedModel?.toLowerCase().includes('codex')
// Only pass temperature and top_p if it's not a GPT/Codex model that rejects them
if (!isTargetModel) {
if (options.params.temperature !== undefined) {
body.temperature = options.params.temperature
}
if (options.params.top_p !== undefined) {
body.top_p = options.params.top_p
}
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...options.defaultHeaders,
Authorization: `Bearer ${options.credentials.apiKey}`,
}
if (options.credentials.accountId) {
headers['chatgpt-account-id'] = options.credentials.accountId
}
headers.originator ??= 'openclaude'
const response = await fetch(`${options.request.baseUrl}/responses`, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: options.signal,
})
if (!response.ok) {
const errorBody = await response.text().catch(() => 'unknown error')
let errorResponse: object | undefined
try { errorResponse = JSON.parse(errorBody) } catch { /* raw text */ }
throw APIError.generate(
response.status, errorResponse,
`Codex API error ${response.status}: ${errorBody}`,
response.headers as unknown as Headers,
)
}
return response
}
async function* readSseEvents(response: Response): AsyncGenerator<CodexSseEvent> {
const reader = response.body?.getReader()
if (!reader) return
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const chunks = buffer.split('\n\n')
buffer = chunks.pop() ?? ''
for (const chunk of chunks) {
const lines = chunk
.split('\n')
.map(line => line.trim())
.filter(Boolean)
if (lines.length === 0) continue
const eventLine = lines.find(line => line.startsWith('event: '))
const dataLines = lines.filter(line => line.startsWith('data: '))
if (!eventLine || dataLines.length === 0) continue
const event = eventLine.slice(7).trim()
const rawData = dataLines.map(line => line.slice(6)).join('\n')
if (rawData === '[DONE]') continue
let data: Record<string, any>
try {
const parsed = JSON.parse(rawData)
if (!parsed || typeof parsed !== 'object') continue
data = parsed as Record<string, any>
} catch {
continue
}
yield { event, data }
}
}
}
function determineStopReason(
response: Record<string, any> | undefined,
sawToolUse: boolean,
): 'end_turn' | 'tool_use' | 'max_tokens' {
const output = Array.isArray(response?.output) ? response.output : []
if (
sawToolUse ||
output.some((item: { type?: string }) => item?.type === 'function_call')
) {
return 'tool_use'
}
const incompleteReason = response?.incomplete_details?.reason
if (
typeof incompleteReason === 'string' &&
incompleteReason.includes('max_output_tokens')
) {
return 'max_tokens'
}
return 'end_turn'
}
export async function collectCodexCompletedResponse(
response: Response,
): Promise<Record<string, any>> {
let completedResponse: Record<string, any> | undefined
for await (const event of readSseEvents(response)) {
if (event.event === 'response.failed') {
const msg = event.data?.response?.error?.message ??
event.data?.error?.message ?? 'Codex response failed'
throw APIError.generate(500, undefined, msg, new Headers())
}
if (
event.event === 'response.completed' ||
event.event === 'response.incomplete'
) {
completedResponse = event.data?.response
break
}
}
if (!completedResponse) {
throw APIError.generate(
500, undefined, 'Codex response ended without a completed payload',
new Headers(),
)
}
return completedResponse
}
export async function* codexStreamToAnthropic(
response: Response,
model: string,
): AsyncGenerator<AnthropicStreamEvent> {
const messageId = makeMessageId()
const toolBlocksByItemId = new Map<
string,
{ index: number; toolUseId: string }
>()
let activeTextBlockIndex: number | null = null
let activeTextBuffer = ''
let textBufferMode: 'none' | 'pending' | 'strip' = 'none'
let nextContentBlockIndex = 0
let sawToolUse = false
let finalResponse: Record<string, any> | undefined
const closeActiveTextBlock = async function* () {
if (activeTextBlockIndex === null) return
if (textBufferMode !== 'none') {
const sanitized = stripLeakedReasoningPreamble(activeTextBuffer)
if (sanitized) {
yield {
type: 'content_block_delta',
index: activeTextBlockIndex,
delta: {
type: 'text_delta',
text: sanitized,
},
}
}
}
yield {
type: 'content_block_stop',
index: activeTextBlockIndex,
}
activeTextBlockIndex = null
activeTextBuffer = ''
textBufferMode = 'none'
}
const startTextBlockIfNeeded = async function* () {
if (activeTextBlockIndex !== null) return
activeTextBlockIndex = nextContentBlockIndex++
yield {
type: 'content_block_start',
index: activeTextBlockIndex,
content_block: { type: 'text', text: '' },
}
}
yield {
type: 'message_start',
message: {
id: messageId,
type: 'message',
role: 'assistant',
content: [],
model,
stop_reason: null,
stop_sequence: null,
usage: makeUsage(),
},
}
for await (const event of readSseEvents(response)) {
const payload = event.data
if (event.event === 'response.output_item.added') {
const item = payload.item
if (item?.type === 'function_call') {
yield* closeActiveTextBlock()
const blockIndex = nextContentBlockIndex++
const toolUseId = item.call_id ?? item.id ?? `call_${blockIndex}`
toolBlocksByItemId.set(String(item.id ?? toolUseId), {
index: blockIndex,
toolUseId,
})
sawToolUse = true
yield {
type: 'content_block_start',
index: blockIndex,
content_block: {
type: 'tool_use',
id: toolUseId,
name: item.name ?? 'tool',
input: {},
},
}
if (item.arguments) {
yield {
type: 'content_block_delta',
index: blockIndex,
delta: {
type: 'input_json_delta',
partial_json: item.arguments,
},
}
}
}
continue
}
if (event.event === 'response.content_part.added') {
if (payload.part?.type === 'output_text') {
yield* startTextBlockIfNeeded()
}
continue
}
if (event.event === 'response.output_text.delta') {
yield* startTextBlockIfNeeded()
activeTextBuffer += payload.delta ?? ''
if (activeTextBlockIndex !== null) {
if (
textBufferMode === 'strip' ||
looksLikeLeakedReasoningPrefix(activeTextBuffer)
) {
textBufferMode = 'strip'
continue
}
if (textBufferMode === 'pending') {
if (shouldBufferPotentialReasoningPrefix(activeTextBuffer)) {
continue
}
yield {
type: 'content_block_delta',
index: activeTextBlockIndex,
delta: {
type: 'text_delta',
text: activeTextBuffer,
},
}
textBufferMode = 'none'
continue
}
if (shouldBufferPotentialReasoningPrefix(activeTextBuffer)) {
textBufferMode = 'pending'
continue
}
yield {
type: 'content_block_delta',
index: activeTextBlockIndex,
delta: {
type: 'text_delta',
text: payload.delta ?? '',
},
}
}
continue
}
if (event.event === 'response.function_call_arguments.delta') {
const toolBlock = toolBlocksByItemId.get(String(payload.item_id ?? ''))
if (toolBlock) {
yield {
type: 'content_block_delta',
index: toolBlock.index,
delta: {
type: 'input_json_delta',
partial_json: payload.delta ?? '',
},
}
}
continue
}
if (event.event === 'response.output_item.done') {
const item = payload.item
if (item?.type === 'function_call') {
const toolBlock = toolBlocksByItemId.get(String(item.id ?? ''))
if (toolBlock) {
yield {
type: 'content_block_stop',
index: toolBlock.index,
}
toolBlocksByItemId.delete(String(item.id))
}
} else if (item?.type === 'message') {
yield* closeActiveTextBlock()
}
continue
}
if (
event.event === 'response.completed' ||
event.event === 'response.incomplete'
) {
finalResponse = payload.response
break
}
if (event.event === 'response.failed') {
const msg = payload?.response?.error?.message ??
payload?.error?.message ?? 'Codex response failed'
throw APIError.generate(500, undefined, msg, new Headers())
}
}
yield* closeActiveTextBlock()
for (const toolBlock of toolBlocksByItemId.values()) {
yield {
type: 'content_block_stop',
index: toolBlock.index,
}
}
yield {
type: 'message_delta',
delta: {
stop_reason: determineStopReason(finalResponse, sawToolUse),
stop_sequence: null,
},
usage: {
// Subtract cached tokens: OpenAI includes them in input_tokens,
// but Anthropic convention treats input_tokens as non-cached only.
input_tokens: (finalResponse?.usage?.input_tokens ?? 0) -
(finalResponse?.usage?.input_tokens_details?.cached_tokens ??
finalResponse?.usage?.prompt_tokens_details?.cached_tokens ?? 0),
output_tokens: finalResponse?.usage?.output_tokens ?? 0,
cache_read_input_tokens:
finalResponse?.usage?.input_tokens_details?.cached_tokens ??
finalResponse?.usage?.prompt_tokens_details?.cached_tokens ??
0,
},
}
yield { type: 'message_stop' }
}
export function convertCodexResponseToAnthropicMessage(
data: Record<string, any>,
model: string,
): Record<string, unknown> {
const content: Array<Record<string, unknown>> = []
const output = Array.isArray(data.output) ? data.output : []
for (const item of output) {
if (item?.type === 'message' && Array.isArray(item.content)) {
for (const part of item.content) {
if (part?.type === 'output_text') {
content.push({
type: 'text',
text: stripLeakedReasoningPreamble(part.text ?? ''),
})
}
}
continue
}
if (item?.type === 'function_call') {
let input: unknown
try {
input = JSON.parse(item.arguments ?? '{}')
} catch {
input = { raw: item.arguments ?? '' }
}
content.push({
type: 'tool_use',
id: item.call_id ?? item.id ?? makeMessageId(),
name: item.name ?? 'tool',
input,
})
}
}
return {
id: data.id ?? makeMessageId(),
type: 'message',
role: 'assistant',
content,
model: data.model ?? model,
stop_reason: determineStopReason(data, content.some(item => item.type === 'tool_use')),
stop_sequence: null,
usage: makeUsage(data.usage),
}
}