diff --git a/bun.lock b/bun.lock index b5b7aa6..d28544c 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@ellipsis/cli", "dependencies": { - "@ellipsis-dev/sdk": "^0.13.0", + "@ellipsis-dev/sdk": "^0.15.0", "chalk": "^5.6.2", "cli-table3": "^0.6.5", "commander": "^12.1.0", @@ -35,7 +35,7 @@ "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], - "@ellipsis-dev/sdk": ["@ellipsis-dev/sdk@0.13.0", "", {}, "sha512-0bc6c5N4dDrx2GLobgkz8M8r8raOIJRs5jkDoAhUOrJ016TmT/rvJwRqNA2jsicF6NbZS0Qfi4KZEKOI8V7L1w=="], + "@ellipsis-dev/sdk": ["@ellipsis-dev/sdk@0.15.0", "", {}, "sha512-Ci6JmhfuKz61uncbJWVpyJP/HVz/7j1uAAVnfe4YMHUS9AnIPY9o0L2bq6S6HVgpLg56Unv42K5y4l7uo2UmYA=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], diff --git a/package.json b/package.json index b580517..85dabfe 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test:watch": "vitest" }, "dependencies": { - "@ellipsis-dev/sdk": "^0.13.0", + "@ellipsis-dev/sdk": "^0.15.0", "chalk": "^5.6.2", "cli-table3": "^0.6.5", "commander": "^12.1.0", diff --git a/src/lib/sessions.ts b/src/lib/sessions.ts index 99c1f60..89c46dc 100644 --- a/src/lib/sessions.ts +++ b/src/lib/sessions.ts @@ -311,10 +311,11 @@ function manufacturerRank(manufacturer: ModelManufacturer | string): number { return at === -1 ? MANUFACTURER_ORDER.length : at } -// Rate-card cents per 1M tokens → "$5", "$0.75". Whole dollars drop the +// Rate-card millicents per 1M tokens → "$5", "$0.75". Whole dollars drop the // ".00": at a glance "$5" is a price, where "$5.00" reads as a table cell. -export function rateDollars(cents: number): string { - return cents % 100 === 0 ? `$${cents / 100}` : `$${(cents / 100).toFixed(2)}` +export function rateDollars(millicents: number): string { + const dollars = millicents / 100_000 + return Number.isInteger(dollars) ? `$${dollars}` : `$${dollars.toFixed(2)}` } // A model's price as two table cells: the two lanes that decide what a session @@ -328,8 +329,8 @@ export function modelRate( ): { input: string; output: string } | null { if (!rate) return null return { - input: rateDollars(rate.input_cents_per_1m_tokens), - output: rateDollars(rate.output_cents_per_1m_tokens), + input: rateDollars(rate.input_millicents_per_1m_tokens), + output: rateDollars(rate.output_millicents_per_1m_tokens), } } diff --git a/src/lib/steps.ts b/src/lib/steps.ts index 6192ec1..4be53d2 100644 --- a/src/lib/steps.ts +++ b/src/lib/steps.ts @@ -29,11 +29,19 @@ interface StepContentBlock { content?: unknown } +// The payload as a loose bag. Every read below is best-effort display text +// across three harness formats (claude_sdk@1, codex_jsonl@1, +// ellipsis_lifecycle@1), so narrowing the SDK's per-format union at each field +// would buy nothing a `typeof` guard doesn't already give. +function fields(record: SessionRecord): Record { + return record.payload as Record +} + // One session_record as a single display line: index, timestamp, record type, // and the first ~120 characters of its text content. Exported for tests. export function formatStepLine(record: SessionRecord): string { - const subtype = - typeof record.payload.subtype === 'string' ? record.payload.subtype : null + const raw = fields(record).subtype + const subtype = typeof raw === 'string' ? raw : null const type = subtype ? `${record.record_type}/${subtype}` : record.record_type return [ String(record.stream_seq).padStart(4), @@ -44,18 +52,17 @@ export function formatStepLine(record: SessionRecord): string { } // Best-effort display text for a stored record. A lifecycle record shows its -// notification line; a claude_code record's `payload` is the raw Claude Code -// stream event — a result step carries `result`, assistant/user steps carry an -// API message whose content is a string or a list of blocks (text, thinking, -// tool_use, tool_result). Anything unrecognized falls back to its JSON. +// notification line; a claude_code record's `payload` is the raw agent stream +// event — a result step carries `result`, assistant/user steps carry `content`, +// a string or a list of blocks (text, thinking, tool_use, tool_result). +// Anything unrecognized falls back to its JSON. export function recordText(record: SessionRecord): string { + const data = fields(record) if (record.source === 'lifecycle') { - return lifecycleText(record.record_type, record.payload) ?? record.record_type + return lifecycleText(record.record_type, data) ?? record.record_type } - const data = record.payload ?? {} if (typeof data.result === 'string') return data.result - const message = data.message as { content?: unknown } | undefined - const text = contentText(message?.content) + const text = contentText(data.content) if (text) return text return JSON.stringify(data) } diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index ca5c32e..8035eca 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -23,13 +23,12 @@ import { sandboxOutputStep, sandboxPhaseLabel, statusActivityText, - type CCEvent, type SessionTranscriptStore, type TranscriptItem, } from '@ellipsis-dev/sdk/store' import { lifecycleText } from '../lib/steps' import { errorDetail } from '../lib/api' -import type { Ellipsis } from '@ellipsis-dev/sdk' +import type { Ellipsis, SdkRecord, SessionRecord } from '@ellipsis-dev/sdk' import { hyperlink } from '../lib/urls' import { usdNumberFromMillicents } from '../lib/output' import { applyEditShortcut } from '../lib/editing' @@ -258,6 +257,9 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { const [elapsed, setElapsed] = useState(0) const [notice, setNotice] = useState(props.initialNotice ?? null) + // How many undisplayable records you've already been told about, so the + // footer warning clears on your next send and only returns if MORE arrive. + const [undisplayedSeen, setUndisplayedSeen] = useState(0) // The slash-command menu's highlighted index. The menu itself is derived from // what is typed (see `menu` below) rather than stored — it is open exactly when // the line starts with `/` and something still matches — so this is the only @@ -303,8 +305,8 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // one-line progress block up top (sandboxProgress), not as transcript rows. // Each turn's closing duration/cost summary is dropped too (see // reshapeTranscript) — the footer carries the session's spend. - const { items } = useMemo(() => { - const shaped = reshapeTranscript(snapshot.records, props.minRenderFeedSeq) + const { items, undisplayed } = useMemo(() => { + const shaped = reshapeTranscript(slice(snapshot.records), props.minRenderFeedSeq) // Client-side notes land at the end: they describe what you just did, so // they belong under everything the server has sent so far. for (const note of chatNotes) { @@ -322,7 +324,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { foldCosts( snapshot.records .filter((r) => r.source === 'claude_code') - .map((r) => r.payload as CCEvent), + .map((r) => r.payload as SdkRecord), ), [snapshot.records], ) @@ -340,7 +342,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // as the durable trace (the sandbox_ready transcript notice is suppressed // below in its favour). const sandbox = useMemo( - () => deriveSandboxState(snapshot.records, props.minRenderFeedSeq), + () => deriveSandboxState(slice(snapshot.records), props.minRenderFeedSeq), [snapshot.records, props.minRenderFeedSeq], ) // Bodies of the server's PENDING inbox messages — the durable queued signal. @@ -490,13 +492,13 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // Which silence a started-but-quiet turn is in ('boot': Claude Code is // still starting in the sandbox; 'turn': the warm agent between records), // for the fallback live line's label. - const awaitingAgent = useMemo(() => awaitingAgentPhase(snapshot.records), [snapshot.records]) + const awaitingAgent = useMemo(() => awaitingAgentPhase(slice(snapshot.records)), [snapshot.records]) // Sends the agent took mid-gap: delivered to the agent but its user-echo // transcript record hasn't landed yet (the echo can lag by a whole sandbox // wake). Rendered as full-colour user rows until the echo replaces them. const acceptedSends = useMemo( - () => deliveredUnechoedSends(snapshot.records), + () => deliveredUnechoedSends(slice(snapshot.records)), [snapshot.records], ) @@ -580,6 +582,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { const text = raw.trim() setComposer({ text: '', cursor: 0 }) if (!text) return + setUndisplayedSeen(undisplayed) // A leading slash claims the line for the CLI. An unknown one is REFUSED, // not forwarded: a typo'd command sent on as prose is a message you did // not mean to send, and the agent cannot tell it from one you did. @@ -644,7 +647,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { } })() }, - [api, exit, pump, sessionId, props.onFocusNav], + [api, exit, pump, sessionId, undisplayed, props.onFocusNav], ) // The composer renders whenever sending is possible. @@ -1099,12 +1102,22 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { const linked = metaParts(hyperlink(props.sessionUrl, sessionId)) .slice(0, kept) .join(META_SEP) - const body = ctrlCArmed ? CTRL_C_QUIT_HINT : plain + // Records that arrived but rendered nothing take the whole line: a shape this + // build can't read means the transcript is lying about what was said, which + // outranks the spend and the session id. It holds the line until the next + // send, and a further drop brings it back with the new count. + const undisplayedNew = Math.max(0, undisplayed - undisplayedSeen) + const dropWarning = + undisplayedNew > 0 + ? `${undisplayedNew} ${undisplayedNew === 1 ? 'event' : 'events'} could not be displayed, this CLI may be out of date` + : null + const body = ctrlCArmed ? CTRL_C_QUIT_HINT : (dropWarning ?? plain) const pad = Math.max(0, Math.floor((cols - body.length) / 2)) // Ink measures the hyperlink's invisible URL bytes as width, so the link // only ships when padding plus escape bytes still fit the row; the padding // gives way first, then the link itself. - const linkedPad = ctrlCArmed ? -1 : Math.min(pad, cols - 1 - linked.length) + const linkedPad = + ctrlCArmed || dropWarning ? -1 : Math.min(pad, cols - 1 - linked.length) const metaLine = linkedPad >= 0 ? `${' '.repeat(linkedPad)}${linked}` @@ -1253,7 +1266,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { )} - {metaLine} + {metaLine} ) @@ -1523,8 +1536,11 @@ export type SandboxState = { log: SandboxLogLine[] } -// The structural slice of a session record the derivations need (the SDK's -// SessionRecordWire is not exported from its store entry). +// The structural slice of a session record the derivations below need. The SDK +// types each harness's payload as its own union (claude_sdk@1, codex_jsonl@1, +// ellipsis_lifecycle@1) with no index signature, and these functions only ever +// read display fields by name across all three — so they take the slice, and +// the cast happens once, in `slice` below. type LifecycleRecordLike = { feed_seq: number source: string @@ -1534,6 +1550,10 @@ type LifecycleRecordLike = { session_message_id?: string | null } +function slice(records: readonly SessionRecord[]): readonly LifecycleRecordLike[] { + return records as readonly LifecycleRecordLike[] +} + // The chat is a LOG of the session: what was said, and what happened to the // session while it was being said. So the milestones — it went to sleep, it is // waking again, it was cancelled — land in the transcript, in feed order, @@ -1545,11 +1565,17 @@ type LifecycleRecordLike = { // bookkeeping, not conversation — the footer's running spend is where that // story lives. An error summary survives as its own (red) line under a plain // label: a failed turn is content. Pure, for tests. +// `undisplayed` counts agent records that arrived and rendered NOTHING — the +// signal for a payload shape this build cannot read (a harness change on the +// server, an out-of-date CLI). Without it such a record is invisible twice +// over: no row, and no hint that a row is missing. Init events are excluded: +// they are deliberately silent here. export function reshapeTranscript( records: readonly LifecycleRecordLike[], minRenderFeedSeq: number, -): { items: TranscriptItem[] } { +): { items: TranscriptItem[]; undisplayed: number } { const items: TranscriptItem[] = [] + let undisplayed = 0 // Index of the "Waking the session…" line still awaiting its outcome, so the // resumed record can settle it in place instead of adding a second row. The // line KEEPS ITS KEY, so settling it doesn't move the scroll anchor or the @@ -1570,14 +1596,23 @@ export function reshapeTranscript( } continue } - const isResult = r.source === 'claude_code' && r.payload.type === 'result' - // recordToItems reads only the structural slice (source, record_type, - // payload); its SessionRecordWire param type isn't exported from the - // SDK's store entry, hence the cast. - for (const item of recordToItems( - r as Parameters[0], - `s${r.feed_seq}`, - )) { + const isResult = r.source === 'claude_code' && r.payload.kind === 'result' + // recordToItems reads the structural slice plus record_format, which it + // switches on; its typed per-harness param isn't the slice these + // derivations pass, hence the cast. It returns undefined for a + // record_format this SDK build has no reader for — a new harness against an + // old CLI — which counts as undisplayed just like an unreadable payload. + // A record the reader THROWS on (an unrecognized payload kind reaches + // blocksOf(undefined) inside the SDK) must cost one row, not the whole + // transcript: the render is the last place that should die of a wire change. + let rendered: TranscriptItem[] + try { + rendered = recordToItems(r as Parameters[0], `s${r.feed_seq}`) ?? [] + } catch { + rendered = [] + } + if (rendered.length === 0 && r.record_type !== 'system') undisplayed++ + for (const item of rendered) { if (item.kind === 'summary' && isResult) { if (item.isError) items.push({ ...item, text: 'turn ended with an error' }) continue @@ -1585,7 +1620,7 @@ export function reshapeTranscript( items.push(item) } } - return { items } + return { items, undisplayed } } // The session milestones worth a line in the chat log, and how each reads. diff --git a/test/connect-app.test.ts b/test/connect-app.test.ts index ed82fdd..e1372e7 100644 --- a/test/connect-app.test.ts +++ b/test/connect-app.test.ts @@ -27,8 +27,17 @@ import { theme } from '../src/lib/theme' import type { TranscriptItem } from '@ellipsis-dev/sdk/store' let seq = 0 +// `record_format` is what recordToItems switches on, so a fixture carries the +// same token the wire does: claude_sdk@1 for agent records, ellipsis_lifecycle@1 +// for platform ones. function rec(recordType: string, payload: Record = {}, source = 'lifecycle') { - return { feed_seq: ++seq, source, record_type: recordType, payload } + return { + feed_seq: ++seq, + source, + record_type: recordType, + record_format: source === 'lifecycle' ? 'ellipsis_lifecycle@1' : 'claude_sdk@1', + payload, + } } describe('deriveSandboxState', () => { @@ -422,11 +431,11 @@ describe('deliveredUnechoedSends', () => { describe('reshapeTranscript', () => { const assistant = (text: string) => - rec('cc', { type: 'assistant', message: { content: [{ type: 'text', text }] } }, 'claude_code') + rec('cc', { kind: 'assistant', content: [{ type: 'text', text }] }, 'claude_code') const result = (over: Record = {}) => rec( 'cc', - { type: 'result', duration_ms: 4000, total_cost_usd: 0.1, is_error: false, ...over }, + { kind: 'result', duration_ms: 4000, cost_usd: 0.1, is_error: false, ...over }, 'claude_code', ) @@ -439,9 +448,9 @@ describe('reshapeTranscript', () => { const { items } = reshapeTranscript( [ assistant('one'), - result({ total_cost_usd: 0.1 }), + result({ cost_usd: 0.1 }), assistant('two'), - result({ total_cost_usd: 0.25, duration_ms: 2000 }), + result({ cost_usd: 0.25, duration_ms: 2000 }), ], 0, ) @@ -453,10 +462,10 @@ describe('reshapeTranscript', () => { }) it('skips records at or below the render cursor (--no-records)', () => { - const hidden = [assistant('old'), result({ total_cost_usd: 0.1 })] + const hidden = [assistant('old'), result({ cost_usd: 0.1 })] const cursor = hidden[hidden.length - 1].feed_seq const { items } = reshapeTranscript( - [...hidden, assistant('new'), result({ total_cost_usd: 0.18 })], + [...hidden, assistant('new'), result({ cost_usd: 0.18 })], cursor, ) expect(items.map((i) => i.text)).toEqual(['new']) @@ -506,6 +515,33 @@ describe('reshapeTranscript', () => { ) expect(items.map((i) => i.text)).toEqual(['hello']) }) + + it('counts agent records that render nothing, so a shape it cannot read is visible', () => { + // A payload this build's reader returns nothing for. + const shapeless = rec('assistant', { kind: 'assistant', content: [] }, 'claude_code') + expect(reshapeTranscript([shapeless], 0)).toEqual({ items: [], undisplayed: 1 }) + expect(reshapeTranscript([assistant('fine'), shapeless], 0).undisplayed).toBe(1) + // A harness this build has no reader for at all: recordToItems returns + // undefined for an unknown record_format, which must count, not throw. + const future = { + ...rec('assistant', { kind: 'assistant' }, 'grok'), + record_format: 'grok_native@1', + } + expect(reshapeTranscript([future], 0)).toEqual({ items: [], undisplayed: 1 }) + // A payload the reader THROWS on (an unknown kind reaches blocksOf) costs + // one row, never the whole transcript. + const hostile = rec('assistant', { kind: 'video' }, 'claude_code') + expect(reshapeTranscript([assistant('before'), hostile], 0)).toEqual({ + items: [expect.objectContaining({ text: 'before' })], + undisplayed: 1, + }) + // A readable transcript never warns, and a silent-by-design init doesn't count. + expect(reshapeTranscript([assistant('fine'), result()], 0).undisplayed).toBe(0) + expect( + reshapeTranscript([rec('system', { type: 'system', subtype: 'init' }, 'claude_code')], 0) + .undisplayed, + ).toBe(0) + }) }) describe('sessionLogText', () => { diff --git a/test/search.test.ts b/test/search.test.ts index f604a7b..b200571 100644 --- a/test/search.test.ts +++ b/test/search.test.ts @@ -198,31 +198,29 @@ describe('recordText / formatStepLine', () => { feed_seq: 3, stream_seq: 3, source: 'claude_code', - record_type: (payload.type as string) ?? 'assistant', + record_type: (payload.kind as string) ?? 'assistant', record_format: 'claude_stream_json@2.0', payload, ...overrides, }) it('reads a result record', () => { - expect(recordText(record({ type: 'result', result: 'All tests pass.' }))).toBe( + expect(recordText(record({ kind: 'result', result: 'All tests pass.' }))).toBe( 'All tests pass.', ) }) it('reads string message content', () => { - expect(recordText(record({ message: { content: 'plain text' } }))).toBe('plain text') + expect(recordText(record({ content: 'plain text' }))).toBe('plain text') }) it('joins text/thinking blocks and summarizes tool calls', () => { const data = { - message: { - content: [ - { type: 'thinking', thinking: 'check the auth flow' }, - { type: 'text', text: 'Reading the file.' }, - { type: 'tool_use', name: 'Read', input: { file_path: 'src/auth.ts' } }, - ], - }, + content: [ + { type: 'thinking', thinking: 'check the auth flow' }, + { type: 'text', text: 'Reading the file.' }, + { type: 'tool_use', name: 'Read', input: { file_path: 'src/auth.ts' } }, + ], } expect(recordText(record(data))).toBe( 'check the auth flow Reading the file. [tool: Read] {"file_path":"src/auth.ts"}', @@ -231,9 +229,7 @@ describe('recordText / formatStepLine', () => { it('unwraps nested tool_result content', () => { const data = { - message: { - content: [{ type: 'tool_result', content: [{ type: 'text', text: 'file contents' }] }], - }, + content: [{ type: 'tool_result', content: [{ type: 'text', text: 'file contents' }] }], } expect(recordText(record(data))).toBe('file contents') }) @@ -246,7 +242,7 @@ describe('recordText / formatStepLine', () => { // record_type + payload.subtype drive the type column; stream_seq the index. const line = formatStepLine( record( - { subtype: 'init', message: { content: 'line one\nline two' } }, + { subtype: 'init', content: 'line one\nline two' }, { record_type: 'system' }, ), ) @@ -280,7 +276,7 @@ describe('recordText / formatStepLine', () => { }) it('truncates long text to about 120 characters', () => { - const line = formatStepLine(record({ message: { content: 'x'.repeat(500) } })) + const line = formatStepLine(record({ content: 'x'.repeat(500) })) expect(line.endsWith('...')).toBe(true) // 4 (index) + 16 (timestamp) + 16 (type) + separators + 120 of text. expect(line.length).toBe(42 + 120) diff --git a/test/sessions.test.ts b/test/sessions.test.ts index f8954ff..02a6dec 100644 --- a/test/sessions.test.ts +++ b/test/sessions.test.ts @@ -382,11 +382,11 @@ function model( manufacturer, is_default_agent_model: false, rate_card: { - input_cents_per_1m_tokens: 5_00, - cache_write_5m_cents_per_1m_tokens: 6_25, - cache_write_1h_cents_per_1m_tokens: 10_00, - cache_read_cents_per_1m_tokens: 50, - output_cents_per_1m_tokens: 25_00, + input_millicents_per_1m_tokens: 5_00_000, + cache_write_5m_millicents_per_1m_tokens: 6_25_000, + cache_write_1h_millicents_per_1m_tokens: 10_00_000, + cache_read_millicents_per_1m_tokens: 50_000, + output_millicents_per_1m_tokens: 25_00_000, }, ...overrides, } @@ -394,9 +394,9 @@ function model( describe('rateDollars', () => { it('drops the cents on a whole dollar and keeps them otherwise', () => { - expect(rateDollars(5_00)).toBe('$5') - expect(rateDollars(75)).toBe('$0.75') - expect(rateDollars(14_25)).toBe('$14.25') + expect(rateDollars(5_00_000)).toBe('$5') + expect(rateDollars(75_000)).toBe('$0.75') + expect(rateDollars(14_25_000)).toBe('$14.25') expect(rateDollars(0)).toBe('$0') }) }) @@ -405,11 +405,11 @@ describe('modelRate', () => { it('quotes the input and output lanes only', () => { expect( modelRate({ - input_cents_per_1m_tokens: 3_00, - cache_write_5m_cents_per_1m_tokens: 3_75, - cache_write_1h_cents_per_1m_tokens: 6_00, - cache_read_cents_per_1m_tokens: 30, - output_cents_per_1m_tokens: 15_00, + input_millicents_per_1m_tokens: 3_00_000, + cache_write_5m_millicents_per_1m_tokens: 3_75_000, + cache_write_1h_millicents_per_1m_tokens: 6_00_000, + cache_read_millicents_per_1m_tokens: 30_000, + output_millicents_per_1m_tokens: 15_00_000, }), ).toEqual({ input: '$3', output: '$15' }) }) @@ -462,11 +462,11 @@ describe('composerModelOptions', () => { model('claude-opus-5', 'anthropic', { is_default_agent_model: true }), model('claude-haiku-4-5-20251001', 'anthropic', { rate_card: { - input_cents_per_1m_tokens: 1_00, - cache_write_5m_cents_per_1m_tokens: 1_25, - cache_write_1h_cents_per_1m_tokens: 2_00, - cache_read_cents_per_1m_tokens: 10, - output_cents_per_1m_tokens: 5_00, + input_millicents_per_1m_tokens: 1_00_000, + cache_write_5m_millicents_per_1m_tokens: 1_25_000, + cache_write_1h_millicents_per_1m_tokens: 2_00_000, + cache_read_millicents_per_1m_tokens: 10_000, + output_millicents_per_1m_tokens: 5_00_000, }, }), ])