diff --git a/.changeset/verify-preview-tunnels.md b/.changeset/verify-preview-tunnels.md new file mode 100644 index 000000000..c76f46554 --- /dev/null +++ b/.changeset/verify-preview-tunnels.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-sandbox-cloudflare': patch +--- + +`exposePreview` now verifies the preview URL is actually reachable before returning it: it fails with an actionable error when nothing is listening on the port, and detects and replaces stale quick tunnels instead of re-sharing dead URLs (#992). diff --git a/packages/ai-sandbox-cloudflare/src/preview-tool.ts b/packages/ai-sandbox-cloudflare/src/preview-tool.ts index 359049b75..9096ee2a9 100644 --- a/packages/ai-sandbox-cloudflare/src/preview-tool.ts +++ b/packages/ai-sandbox-cloudflare/src/preview-tool.ts @@ -68,6 +68,87 @@ export const PREVIEW_GUIDANCE: string = [ 'Once it is listening, call `exposePreview` with that port, then share the URL.', ].join('\n') +const LOCAL_PROBE_TIMEOUT_MS = 5_000 +// Fresh quick tunnels need a few seconds of DNS/edge propagation, hence retries. +const EDGE_PROBE_ATTEMPTS = 5 +const EDGE_PROBE_BASE_DELAY_MS = 250 +const EDGE_PROBE_FETCH_TIMEOUT_MS = 3_000 + +/** + * Probe the port INSIDE the sandbox via `containerFetch`. Returns the HTTP + * status when a listener answered (any response — 4xx/5xx included — proves one + * exists), or the failure symptom (a string) when nothing did. + */ +async function localProbe( + sandbox: Sandbox, + port: number, +): Promise { + // A race instead of AbortSignal: signals don't serialize across the sandbox + // RPC boundary, and a lost in-flight probe response is harmless. + let timeoutId: ReturnType | undefined + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout( + () => reject(new Error(`no response within ${LOCAL_PROBE_TIMEOUT_MS}ms`)), + LOCAL_PROBE_TIMEOUT_MS, + ) + }) + try { + const res = await Promise.race([ + sandbox.containerFetch('http://preview/', { method: 'HEAD' }, port), + timeout, + ]) + return res.status + } catch (error) { + return error instanceof Error ? error.message : String(error) + } finally { + clearTimeout(timeoutId) + } +} + +/** + * Probe a tunnel URL through the public edge with bounded retries. Only 502/530 + * — Cloudflare's tunnel/origin-unreachable signatures — mark the URL as + * unreachable (401/403/404 prove the server answered, and `redirect: 'manual'` + * keeps a login redirect from probing some other site), and even those are + * trusted when the app answered the SAME status locally, so an app's own + * 502/530 never gets its healthy tunnel destroyed. The verdict split exists + * because only an OBSERVED non-matching 502/530 ('stale') is evidence that + * justifies destroying the tunnel; fetch exceptions ('unverified') prove + * nothing about it — the probe path itself may be what failed. + */ +async function edgeProbeFailure( + url: string, + localStatus: number, +): Promise<{ verdict: 'stale' | 'unverified'; symptom: string } | null> { + let lastFailure = 'no response' + let verdict: 'stale' | 'unverified' = 'unverified' + for (let attempt = 0; attempt < EDGE_PROBE_ATTEMPTS; attempt += 1) { + if (attempt > 0) { + await new Promise((resolve) => + setTimeout(resolve, EDGE_PROBE_BASE_DELAY_MS * 2 ** (attempt - 1)), + ) + } + try { + const res = await fetch(url, { + method: 'HEAD', + redirect: 'manual', + signal: AbortSignal.timeout(EDGE_PROBE_FETCH_TIMEOUT_MS), + }) + if ( + (res.status !== 502 && res.status !== 530) || + res.status === localStatus + ) { + return null + } + lastFailure = `HTTP ${res.status}` + verdict = 'stale' + } catch (error) { + lastFailure = error instanceof Error ? error.message : String(error) + } + } + return { verdict, symptom: lastFailure } +} + /** * Build the `exposePreview` server tool for one run. Starting a tunnel is a * HOST-side call on the Sandbox DO stub, so an in-sandbox agent cannot make it from @@ -100,11 +181,51 @@ export function exposePreviewTool(input: StartRunInput, env: PreviewToolEnv) { const sandbox = getSandbox(env.Sandbox, input.threadId, { transport: 'rpc', }) + // Gate tunnel work on a live listener: a fresh tunnel to a dead port is still + // a dead preview, and the failure the agent can FIX is "start the server". + const local = await localProbe(sandbox, port) + if (typeof local === 'string') { + throw new Error( + `No server is listening on port ${port} inside the sandbox (${local}). Start the dev server (bound to 0.0.0.0:${port}) first, then retry exposePreview.`, + ) + } // A Cloudflare quick tunnel (`*.trycloudflare.com`) run by `cloudflared` INSIDE // the sandbox: it bypasses the local Vite dev server's port entirely (so Vite // can't hijack the preview's asset requests) and needs no custom domain on a // deploy. `get(port)` is idempotent per port. See the Sandbox SDK `tunnels` API. const tunnel = await sandbox.tunnels.get(port) - return { url: tunnel.url } + const edgeFailure = await edgeProbeFailure(tunnel.url, local) + if (edgeFailure === null) return { url: tunnel.url } + // Never destroy on 'unverified': the tunnel may be healthy with only the + // probe path broken, so destroying it could kill a working preview. + if (edgeFailure.verdict === 'unverified') { + throw new Error( + `Port ${port} is serving inside the sandbox, but the preview tunnel could not be verified from the edge (${edgeFailure.symptom}). The tunnel was left in place — retry exposePreview in a few seconds.`, + ) + } + // Local server healthy but the edge kept answering 502/530: the cached tunnel + // record is suspect. Refresh, bounded to ONE so we never churn tunnels. + await sandbox.tunnels.destroy(port) + const fresh = await sandbox.tunnels.get(port) + const freshFailure = await edgeProbeFailure(fresh.url, local) + if (freshFailure === null) { + return { + url: fresh.url, + note: `The tunnel for port ${port} was stale, so it was replaced. Any previously shared preview URL for this port is dead — share this new URL instead.`, + } + } + const [diagnosis, hint] = + freshFailure.verdict === 'stale' + ? [ + 'its preview tunnel never became reachable', + 'Retry exposePreview, and if it keeps failing, restart the dev server and try again.', + ] + : [ + 'the replacement preview tunnel could not be verified from the edge', + 'Retry exposePreview in a few seconds.', + ] + throw new Error( + `Port ${port} is serving inside the sandbox, but ${diagnosis} (old tunnel: ${edgeFailure.symptom}; replacement tunnel: ${freshFailure.symptom}). ${hint}`, + ) }) } diff --git a/packages/ai-sandbox-cloudflare/tests/preview-tool.test.ts b/packages/ai-sandbox-cloudflare/tests/preview-tool.test.ts index 64fd9e261..f64990985 100644 --- a/packages/ai-sandbox-cloudflare/tests/preview-tool.test.ts +++ b/packages/ai-sandbox-cloudflare/tests/preview-tool.test.ts @@ -1,11 +1,12 @@ /** * Deterministic tests for the browser-preview building blocks (no Workers - * runtime). `getSandbox` is module-mocked to a stub whose `tunnels.get` records - * its calls, so we can assert the tool opens a quick tunnel to the right port and - * returns its URL, and that `PREVIEW_GUIDANCE` carries the directives an agent - * needs (allow-all-hosts, non-3000 port, call exposePreview). + * runtime). `getSandbox` is module-mocked to a stub recording `containerFetch` + * (the in-container listener probe) and `tunnels.get`/`tunnels.destroy`, and the + * global `fetch` (the public edge probe) is stubbed, so the tool's + * verify-then-return contract is assertable without a sandbox. `PREVIEW_GUIDANCE` + * is asserted to carry the directives an agent needs. */ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Sandbox } from '@cloudflare/sandbox' import type { StartRunInput } from '../src/coordinator' @@ -14,33 +15,67 @@ import type { StartRunInput } from '../src/coordinator' // lib's) — use it bare, the way the package's own modules do. // Hoisted so the `vi.mock` factory can close over the same spies the tests assert on. -const { tunnelGetMock, getSandboxMock } = vi.hoisted(() => { - const tunnelGetMock = vi.fn<(port: number) => Promise<{ url: string }>>() - return { - tunnelGetMock, - getSandboxMock: vi.fn(() => ({ tunnels: { get: tunnelGetMock } })), - } -}) +const { containerFetchMock, tunnelGetMock, tunnelDestroyMock, getSandboxMock } = + vi.hoisted(() => { + const containerFetchMock = + vi.fn< + (url: string, init: RequestInit, port: number) => Promise + >() + const tunnelGetMock = vi.fn<(port: number) => Promise<{ url: string }>>() + const tunnelDestroyMock = vi.fn<(port: number) => Promise>() + return { + containerFetchMock, + tunnelGetMock, + tunnelDestroyMock, + getSandboxMock: vi.fn(() => ({ + containerFetch: containerFetchMock, + tunnels: { get: tunnelGetMock, destroy: tunnelDestroyMock }, + })), + } + }) vi.mock('@cloudflare/sandbox', () => ({ getSandbox: getSandboxMock })) +// The edge probe goes through the global `fetch` (a plain Worker subrequest). +const edgeFetchMock = + vi.fn<(url: string, init?: RequestInit) => Promise>() +vi.stubGlobal('fetch', edgeFetchMock) + // Imported AFTER the mock is registered. const { PREVIEW_GUIDANCE, exposePreviewTool } = await import('../src/preview-tool') const SANDBOX = {} as unknown as DurableObjectNamespace +const OLD_URL = 'https://two-words-here.trycloudflare.com' +const NEW_URL = 'https://fresh-words-here.trycloudflare.com' + +const http = (status: number) => new Response(null, { status }) -function runInput(overrides: Partial = {}): StartRunInput { - return { runId: 'r1', threadId: 'thread-x', messages: [], ...overrides } +function makeTool() { + const input: StartRunInput = { + runId: 'r1', + threadId: 'thread-x', + messages: [], + } + return exposePreviewTool(input, { Sandbox: SANDBOX }) } -describe('exposePreviewTool', () => { - it('opens a quick tunnel on the run’s container for the given port', async () => { - tunnelGetMock.mockResolvedValue({ - url: 'https://two-words-here.trycloudflare.com', - }) - const tool = exposePreviewTool(runInput(), { Sandbox: SANDBOX }) +beforeEach(() => { + getSandboxMock.mockClear() + // mockReset, not mockClear: leftover `mockResolvedValueOnce` queues from one + // test would leak into the next. + containerFetchMock.mockReset().mockResolvedValue(http(200)) + tunnelGetMock.mockReset().mockResolvedValue({ url: OLD_URL }) + tunnelDestroyMock.mockReset().mockResolvedValue(undefined) + edgeFetchMock.mockReset().mockResolvedValue(http(200)) +}) - const result = await tool.execute?.({ port: 5173 }) +afterEach(() => { + vi.useRealTimers() +}) + +describe('exposePreviewTool', () => { + it('opens a quick tunnel on the run’s container and returns its URL once verified', async () => { + const result = await makeTool().execute?.({ port: 5173 }) // The run's container is addressed by threadId, over the RPC transport that // `sandbox.tunnels` requires; the tunnel targets the dev port. @@ -48,7 +83,93 @@ describe('exposePreviewTool', () => { transport: 'rpc', }) expect(tunnelGetMock).toHaveBeenCalledWith(5173) - expect(result).toEqual({ url: 'https://two-words-here.trycloudflare.com' }) + expect(result).toEqual({ url: OLD_URL }) + }) + + it('fails with an actionable error — and mints no tunnel — when nothing listens on the port', async () => { + vi.useFakeTimers() + // A probe that never settles: the tool's own timeout must bound it. + containerFetchMock.mockReturnValue(new Promise(() => {})) + + const promise = makeTool().execute?.({ port: 5173 }) + const assertion = expect(promise).rejects.toThrow(/listening on port 5173/) + await vi.runAllTimersAsync() + await assertion + + expect(tunnelGetMock).not.toHaveBeenCalled() + }) + + it('counts local 5xx as a live listener and edge 4xx / transient failures as reachable', async () => { + vi.useFakeTimers() + // An auth-protected app: 500 locally, and the edge needs one propagation + // retry before answering 403. None of that means the tunnel is stale. + containerFetchMock.mockResolvedValue(http(500)) + edgeFetchMock + .mockRejectedValueOnce(new Error('DNS not propagated')) + .mockResolvedValue(http(403)) + + const promise = makeTool().execute?.({ port: 5173 }) + await vi.runAllTimersAsync() + + await expect(promise).resolves.toEqual({ url: OLD_URL }) + expect(tunnelDestroyMock).not.toHaveBeenCalled() + }) + + it('does not replace the tunnel when the app itself answers 502 (same status locally and at the edge)', async () => { + containerFetchMock.mockResolvedValue(http(502)) + edgeFetchMock.mockResolvedValue(http(502)) + + const result = await makeTool().execute?.({ port: 5173 }) + + expect(result).toEqual({ url: OLD_URL }) + expect(tunnelDestroyMock).not.toHaveBeenCalled() + }) + + it('leaves the tunnel alone when the edge probe only ever throws (unverified, not stale)', async () => { + vi.useFakeTimers() + // Timeouts/DNS failures say nothing about the tunnel — it must survive. + edgeFetchMock.mockRejectedValue(new Error('subrequest failed')) + + const promise = makeTool().execute?.({ port: 5173 }) + const assertion = expect(promise).rejects.toThrow(/could not be verified/) + await vi.runAllTimersAsync() + await assertion + + expect(tunnelDestroyMock).not.toHaveBeenCalled() + }) + + it('replaces a stale tunnel (local healthy, edge stuck on 502) and flags the old URL as dead', async () => { + vi.useFakeTimers() + tunnelGetMock + .mockResolvedValueOnce({ url: OLD_URL }) + .mockResolvedValueOnce({ url: NEW_URL }) + // The stale tunnel 502s no matter how often it's probed; the fresh one works. + edgeFetchMock.mockImplementation((url) => + Promise.resolve(http(url === NEW_URL ? 200 : 502)), + ) + + const promise = makeTool().execute?.({ port: 5173 }) + await vi.runAllTimersAsync() + + await expect(promise).resolves.toMatchObject({ + url: NEW_URL, + note: expect.stringMatching(/dead/), + }) + expect(tunnelDestroyMock).toHaveBeenCalledWith(5173) + }) + + it('errors instead of returning a URL when the replacement tunnel never becomes reachable', async () => { + vi.useFakeTimers() + edgeFetchMock.mockResolvedValue(http(502)) + + const promise = makeTool().execute?.({ port: 5173 }) + // Attach the rejection handler BEFORE running the timers, or the rejection + // fires unhandled while the fake clock advances. + const assertion = expect(promise).rejects.toThrow( + /Port 5173 is serving inside the sandbox/, + ) + await vi.runAllTimersAsync() + await assertion }) }) diff --git a/scripts/lovable-gateway.models.json b/scripts/lovable-gateway.models.json index 8a193376e..79c6babbc 100644 --- a/scripts/lovable-gateway.models.json +++ b/scripts/lovable-gateway.models.json @@ -12,15 +12,8 @@ "max_tokens": 65536, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -85,15 +78,8 @@ "max_tokens": 65536, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text", - "image" - ] + "input": ["text", "image", "video"], + "output": ["text", "image"] }, "pricing": { "input": { @@ -150,15 +136,8 @@ "max_tokens": 65536, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -222,12 +201,8 @@ "context_window": 8192, "max_tokens": 16384, "modalities": { - "input": [ - "text" - ], - "output": [ - "audio" - ] + "input": ["text"], + "output": ["audio"] }, "pricing": { "input": { @@ -265,12 +240,8 @@ "max_tokens": 16384, "knowledge": "2025-01", "modalities": { - "input": [ - "text" - ], - "output": [ - "audio" - ] + "input": ["text"], + "output": ["audio"] }, "pricing": { "input": { @@ -308,15 +279,8 @@ "max_tokens": 65536, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -406,12 +370,8 @@ "max_tokens": 16384, "knowledge": "2025-01", "modalities": { - "input": [ - "text" - ], - "output": [ - "audio" - ] + "input": ["text"], + "output": ["audio"] }, "pricing": { "input": { @@ -449,15 +409,8 @@ "max_tokens": 65000, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -522,15 +475,8 @@ "max_tokens": 32768, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text", - "image" - ] + "input": ["text", "image", "video"], + "output": ["text", "image"] }, "pricing": { "input": { @@ -595,15 +541,8 @@ "max_tokens": 32768, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text", - "image" - ] + "input": ["text", "image", "video"], + "output": ["text", "image"] }, "pricing": { "input": { @@ -668,15 +607,8 @@ "max_tokens": 65000, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -740,15 +672,8 @@ "context_window": 65536, "max_tokens": 4096, "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text", - "image" - ] + "input": ["text", "image", "video"], + "output": ["text", "image"] }, "pricing": { "input": { @@ -813,12 +738,8 @@ "max_tokens": 16384, "knowledge": "2025-01", "modalities": { - "input": [ - "text" - ], - "output": [ - "audio" - ] + "input": ["text"], + "output": ["audio"] }, "pricing": { "input": { @@ -856,15 +777,8 @@ "max_tokens": 64000, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -954,15 +868,8 @@ "max_tokens": 64000, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -1027,15 +934,8 @@ "max_tokens": 64000, "knowledge": "2026-03", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -1100,12 +1000,8 @@ "max_tokens": 0, "knowledge": "2025-05", "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] + "input": ["text"], + "output": ["text"] }, "pricing": { "input": { @@ -1133,15 +1029,8 @@ "max_tokens": 0, "knowledge": "2025-11", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -1192,13 +1081,8 @@ "context_window": 0, "max_tokens": 0, "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "video" - ] + "input": ["text", "image"], + "output": ["video"] }, "pricing": { "video_duration": { @@ -1226,13 +1110,8 @@ "context_window": 0, "max_tokens": 0, "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "video" - ] + "input": ["text", "image"], + "output": ["video"] }, "pricing": { "video_duration": { @@ -1260,13 +1139,8 @@ "context_window": 0, "max_tokens": 0, "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "video" - ] + "input": ["text", "image"], + "output": ["video"] }, "pricing": { "video_duration": { @@ -1291,13 +1165,8 @@ "context_window": 16000, "max_tokens": 2000, "modalities": { - "input": [ - "text", - "audio" - ], - "output": [ - "text" - ] + "input": ["text", "audio"], + "output": ["text"] }, "pricing": { "input": { @@ -1342,12 +1211,8 @@ "context_window": 2000, "max_tokens": 0, "modalities": { - "input": [ - "text" - ], - "output": [ - "audio" - ] + "input": ["text"], + "output": ["audio"] }, "pricing": { "input": { @@ -1384,13 +1249,8 @@ "context_window": 16000, "max_tokens": 2000, "modalities": { - "input": [ - "text", - "audio" - ], - "output": [ - "text" - ] + "input": ["text", "audio"], + "output": ["text"] }, "pricing": { "input": { @@ -1436,13 +1296,8 @@ "max_tokens": 128000, "knowledge": "2024-09-30", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1526,13 +1381,8 @@ "max_tokens": 128000, "knowledge": "2024-05-30", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1616,13 +1466,8 @@ "max_tokens": 128000, "knowledge": "2024-05-30", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1671,13 +1516,8 @@ "max_tokens": 128000, "knowledge": "2024-10", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1761,13 +1601,8 @@ "max_tokens": 128000, "knowledge": "2025-08-31", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1866,13 +1701,8 @@ "max_tokens": 128000, "knowledge": "2025-08-31", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1956,13 +1786,8 @@ "max_tokens": 128000, "knowledge": "2025-08-31", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2011,13 +1836,8 @@ "max_tokens": 128000, "knowledge": "2025-08-31", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2081,13 +1901,8 @@ "max_tokens": 128000, "knowledge": "2025-12-01", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2186,13 +2001,8 @@ "max_tokens": 128000, "knowledge": "2025-12-01", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2256,13 +2066,8 @@ "max_tokens": 128000, "knowledge": "2026-02-16", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2361,13 +2166,8 @@ "max_tokens": 128000, "knowledge": "2026-02-16", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2466,13 +2266,8 @@ "max_tokens": 128000, "knowledge": "2026-02-16", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2570,13 +2365,8 @@ "context_window": 0, "max_tokens": 0, "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "image" - ] + "input": ["text", "image"], + "output": ["image"] }, "pricing": { "input": { @@ -2621,13 +2411,8 @@ "context_window": 0, "max_tokens": 0, "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "image" - ] + "input": ["text", "image"], + "output": ["image"] }, "pricing": { "input": { @@ -2672,12 +2457,8 @@ "context_window": 8191, "max_tokens": 0, "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] + "input": ["text"], + "output": ["text"] }, "pricing": { "input": { @@ -2704,12 +2485,8 @@ "context_window": 8191, "max_tokens": 0, "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] + "input": ["text"], + "output": ["text"] }, "pricing": { "input": {