# R5 capability census — AI pilot harvest internals (read-only)

Census date: 2026-06-15. Donor = multideal `apps/web/src/server/ai/` (primary); cross-checks = zync.is, fewtok. Platform shipped surface = `packages/ai` (`index.ts` chat-completion + fallback runner, `openai-compat`, `anthropic` only). No verdicts.

---

## LLM job lifecycle / runner
- donor: multideal/apps/web/src/server/ai/llm.ts:77 — `enqueueLlmJob(db, input)` inserts `PENDING` row in `llm_jobs`, returns job id; sole enqueue seam
- shape: `enqueueLlmJob(db: DrizzleClient, input: EnqueueLlmJobInput): Promise<string>`; `enqueueVendorAdmissionReviewOnce(db, vendorId): Promise<string | null>`
- deps: db
- maturity: multideal richest

- donor: multideal/apps/web/src/server/queues/llm-jobs-producer.ts:60 — `sendLlmJobToQueue` CF-queue send + `nudgeJobRunner` DO alarm after enqueue
- shape: `sendLlmJobToQueue(env, jobId, ctx?): Promise<void>`; `nudgeJobRunner(env, ctx?): Promise<void>`
- deps: jobs(queue/runner/cron)
- maturity: multideal richest

- donor: multideal/apps/web-do/src/do/JobRunnerDO.ts:26 — primary processor: DO alarm picks 1 FIFO `PENDING` job, calls `runJob`, self-reschedules 1.5s while backlog
- shape: `class JobRunnerDO extends DurableObject`; `nudge(): Promise<void>`; `alarm(): Promise<void>`
- deps: jobs(queue/runner/cron) | db
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/runner.ts:137 — `runJob` single execution seam: load → kind lookup → credential resolve → race-guard `RUNNING` → `loadInput` → `runProvider` → `applyVerdict` → `markJobCompleted`; never throws, returns `RunResult`
- shape: `runJob(env: RunJobEnv, jobId: string, db: DrizzleClient): Promise<RunResult>`; `RunResult = { status: 'ok'|'retry'|'fatal'; reason?: string }`
- deps: db | jobs(queue/runner/cron)
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/runner-helpers.ts:40 — status transitions on `llm_jobs`: `transitionToRunning`, `markJobFailed` (terminal flag), `markJobCompleted` (persists `outputPayload` + usage columns)
- shape: `transitionToRunning(db, jobId): Promise<boolean>`; `markJobFailed(db, jobId, error, opts?): Promise<void>`; `markJobCompleted(db, jobId, result, meta?): Promise<void>`
- deps: db
- maturity: multideal richest

- donor: multideal/apps/web/src/server/admin/resources/llm-jobs/actions.ts:15 — `retryLlmJob` bumps `retry_count`, resets to `PENDING`, clears `outputPayload`; cap `retryCount < 3`
- shape: `retryLlmJob(db: DrizzleClient, id: string): Promise<boolean>`
- deps: db
- maturity: multideal richest

- donor: multideal/apps/web/src/server/cron/process-llm-jobs.ts:54 — 30-min backstop cron: reap stale `RUNNING` (>10 min) → process 1 pending via `runJob` → retry stale `FAILED` without `completedAt` → SLA alerts; composes generic cron, bespoke to `llm_jobs`
- shape: `runProcessLlmJobs(env: LlmCronEnv): Promise<void>`
- deps: jobs(queue/runner/cron) | db | notifications
- maturity: multideal richest

- donor: multideal/apps/web/src/server/queues/llm-jobs-consumer.ts:19 — CF Queue batch handler delegates full `runProcessLlmJobs` sweep (Phase 1 coarse; message shape `{ jobId: string }`)
- shape: `handleLlmJobsBatch(batch: MessageBatch<{ jobId: string }>, env: LlmCronEnv): Promise<void>`
- deps: jobs(queue/runner/cron)
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/credentials.ts:20 — per-job provider+model resolution: `jobType` → `llm_queues` chain → `buildChainedProvider`
- shape: `resolveQueueForJobType(db, piiKey, jobType): Promise<{ provider: ChainedLlmProvider; model: string }>`
- deps: db
- maturity: multideal richest

## JobKind interface
- donor: multideal/apps/web/src/server/ai/kinds/types.ts:65 — per-kind plugin contract wired by runner
- shape: `interface JobKind<TInput, TResult, TVerdict>` with `jobType`, `loadInput(deps, jobRow)`, `runProvider(deps, jobRow, input)`, `hydrateResult(jobRow)`, `applyVerdict(deps, jobRow, result)`, `onRetryExhausted(db, jobRow, reason)`, optional `extractCompletionMeta?(result)`; `JobRunDeps { db, provider, creds, model, r2Bucket? }`; `LlmJobRow`; `JobCompletionMeta`
- deps: db | uploads
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/kinds/registry.ts:26 — compile-time exhaustive `LlmJobType` → kind map; 4 kinds registered, `REVIEW_PRESCORING: undefined` (sync-only)
- shape: `JOB_KIND_REGISTRY: Record<LlmJobType, JobKind<unknown, unknown, unknown> | undefined>`
- deps: none
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/kinds/deal-moderation.ts:139 — `dealModerationKind` plugs `DEAL_MODERATION` into registry
- shape: `dealModerationKind: JobKind<DealModerationInput, DealModerationResult, DealModerationVerdict>`
- deps: db | uploads
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/kinds/image-approval.ts:65 — `imageApprovalKind` plugs `IMAGE_APPROVAL`; loads R2 bytes, delegates to `UploadImageModerationAgent`
- shape: `imageApprovalKind: JobKind<ImageApprovalInput, ImageApprovalResult, ImageApprovalResult>`
- deps: db | uploads
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/kinds/translation.ts:80 — `translationKind` plugs `TRANSLATION`; Hebrew→English deal fields
- shape: `translationKind: JobKind<TranslationInput, TranslationResult, TranslationResult>`
- deps: db
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/kinds/vendor-violation.ts:64 — `vendorViolationKind` plugs `VENDOR_VIOLATION` (admission review path); delegates to `GeminiVendorViolationFlagAgent.review()`
- shape: `vendorViolationKind: JobKind<VendorViolationInput, VendorViolationResult, VendorViolationVerdict>`
- deps: db
- maturity: multideal richest

## Provider adapters beyond the 2 shipped
- donor: multideal/apps/web/src/server/ai/providers/types.ts:56 — adapter interface all providers implement (distinct from platform `AIAdapter`)
- shape: `interface LlmProvider { id: ProviderId; supportsImage: boolean; generateText(creds, req: TextRequest): Promise<NormalizedTextResult>; generateTextWithImage(creds, req: ImageRequest): Promise<NormalizedTextResult> }`; `ProviderId = 'gemini'|'openai'|'anthropic'|'mock'`; error classes `ProviderRateLimitError|ProviderTransientError|ProviderQuotaExhaustedError|ProviderAuthError|ProviderFatalError|ProviderNotImplementedError`
- deps: none
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/providers/gemini.ts:169 — Google Gemini via `@google/genai`; inline `pricePerMillionTokens` + `computeCostUsd`; 60s timeout; maps SDK errors to provider taxonomy
- shape: `geminiProvider: LlmProvider`; `export const geminiProvider`
- deps: none
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/gemini.ts:47 — legacy parallel Gemini client (`createGeminiClient`, `generateTextWithUsage`, `generateTextWithImageWithUsage`) used by older agent classes before `LlmProvider` migration
- shape: `createGeminiClient(apiKey: string): GeminiClient`; `generateTextWithUsage(client, prompt, model?): Promise<GeminiTextWithUsage>`; `DEFAULT_LLM_MODEL`, `DEFAULT_FALLBACK_MODEL`
- deps: none
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/providers/openai.ts:106 — OpenAI-compat fetch adapter (`createOpenAICompatProvider(baseUrl)`); default `openaiProvider` → `api.openai.com`; `supportsImage: false`
- shape: `createOpenAICompatProvider(baseUrl: string): LlmProvider`; `openaiProvider: LlmProvider`
- deps: none
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/providers/anthropic.ts:208 — Anthropic SDK adapter; text + vision (`supportsImage: true`); inline cost tiers
- shape: `anthropicProvider: LlmProvider`
- deps: none
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/providers/mock.ts:1 — deterministic test adapter (`TEST*` model directives, `@@MOCK:…@@` sentinels); simulates rate-limit/quota/auth failures
- shape: `mockProvider: LlmProvider`; `MockResponseKind`
- deps: none
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/providers/chained.ts:45 — multi-provider fallback chain built from DB `llm_providers` rows (decrypt api key via `piiKey`)
- shape: `class ChainedLlmProvider implements LlmProvider`; `buildChainedProvider(db, piiKey, config: LlmChainConfig): Promise<ChainedLlmProvider>`; `LlmChainConfig { chain: Array<{ llmProviderId, model }> }`
- deps: db
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/providers/registry.ts:8 — static provider map; `IMPLEMENTED_PROVIDER_IDS` = `['gemini','mock']` only — openai/anthropic coded but admin-blocked as unfulfilled
- shape: `PROVIDER_REGISTRY: Record<ProviderId, LlmProvider>`; `getProvider(id): LlmProvider`; `IMPLEMENTED_PROVIDER_IDS: ProviderId[]`
- deps: none
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/providers/listModels.ts:1 — per-provider model catalog fetcher (Gemini OpenAI-compat models endpoint, openai-compat `/models`, mock list)
- shape: `ModelOption { id, label }`; `ProviderModelsError`; `MOCK_MODELS`
- deps: none
- maturity: multideal richest

- donor: platform/packages/ai/src/index.ts:130 — platform declares `AIProvider = 'anthropic'|'openai-compat'|'google'|'workers-ai'` but only registers anthropic + openai-compat factories; `getAdapter('google')` throws `FatalError` at runtime
- shape: `type AIProvider`; `getAdapter(provider, creds): AIAdapter`; `setAdapterFactories(factories)`
- deps: none
- maturity: multideal richest (google adapter exists in donor; platform slot is empty promise)

## usage → price layer
- donor: multideal/apps/web/src/server/ai/pricing.ts:106 — canonical model→rate table `GEMINI_PRICING` + `computeCostUsd(modelName, promptTokens, completionTokens): number | null`
- shape: `interface ModelPricing { promptUsdPerMillion; completionUsdPerMillion }`; `GEMINI_PRICING: Record<string, ModelPricing>`; `computeCostUsd(...)`
- deps: none
- maturity: multideal richest for LLM-job cost seam; zync.is richer for tenant billing schema

- donor: multideal/apps/web/src/server/ai/providers/types.ts:39 — provider responses carry token usage AND optional `costUsd` on `NormalizedTextResult`
- shape: `NormalizedTextResult { text, model, usage: { promptTokens, completionTokens, totalTokens }, costUsd: number | null, rawResponse }`
- deps: none
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/providers/gemini.ts:70 — duplicate inline pricing in gemini provider (`pricePerMillionTokens` regex tiers) — parallel to `pricing.ts`
- shape: `pricePerMillionTokens(model: string): { in, out } | null`; inline `computeCostUsd` in provider module
- deps: none
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/kinds/types.ts:50 — job completion persists usage+cost to `llm_jobs` columns via `JobCompletionMeta`
- shape: `JobCompletionMeta { modelName?, promptTokens?, completionTokens?, totalTokens?, costUsd?: string }`
- deps: db
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/kinds/translation.ts:64 — kind-local hardcoded flash-lite rates (`calcCostUsd`) instead of `pricing.ts` lookup
- shape: `calcCostUsd(promptTokens, completionTokens): number`
- deps: none
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/agents/support/agent.ts:202 — agent loop accumulates per-turn `computeCostUsd` into `runState.cumulativeCostUsd`, enforces `config.maxCostCents` cap
- shape: `runState.cumulativeCostUsd`; cost gate before tool dispatch
- deps: db
- maturity: multideal richest

- donor: zync.is/packages/db/src/schema/ai.ts:69 — DB-native pricing + usage accounting: `ai_model_pricing`, `ai_usage_log` (tokens + `costUsd` + `billedFrom`), tier quotas, credit purchases
- shape: `aiModelPricing`, `aiUsageLog`, `aiTierQuotas`, `aiCreditPurchases` tables; `AIModelPricingRow`, `AIUsageLogRow`
- deps: db
- maturity: zync.is richer for multi-tenant price/quota/billing substrate (`packages/db/src/schema/ai.ts`)

- donor: fewtok/src/stats/pricing.ts:29 — proxy-side cost math with cache-aware pricing (`cachedInputPerKTok`, `cacheCreatePerKTok`, counterfactual savings)
- shape: `costUsd(price: ModelPrice, input: CostInput): number`; `counterfactual(price, input): CounterfactualCosts`; `priceFor(provider, model)` in `pricing/models.ts`
- deps: none
- maturity: fewtok richer for cache-tier + counterfactual cost (`src/stats/pricing.ts`)

- donor: platform/packages/ai/src/index.ts:188 — platform explicitly rejects cost in adapter usage boundary (`assertUsageBoundary` allowlists only `promptTokens`/`completionTokens`)
- shape: `assertUsageBoundary(usage: AIUsage): void`; comment "cost is host-domain"
- deps: none
- maturity: platform intentionally lacks price layer (by design); multideal donor has host-side cost above adapter

## PII redactor
- donor: multideal/apps/web/src/server/ai/agents/support/redactor.ts:56 — builds deterministic pseudo-id map for user/vendor emails+phones
- shape: `buildRedactionMap(users: Map<string, UserPII>, vendors: Map<string, VendorPII>): RedactionMap`; `UserPII`, `VendorPII`, `RedactionMap { forward, reverse }`
- deps: none
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/agents/support/redactor.ts:126 — pre-send scrub: `redactForPrompt(text, map)` replaces email literals + digit-normalized phone patterns with `{user_a}`/`{vendor_a}` tokens
- shape: `redactForPrompt(text: string, map: RedactionMap): string`
- deps: none
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/agents/support/redactor.ts:180 — post-response de-anonymize: `substitutePseudoIds(text, map)` for privileged display/storage
- shape: `substitutePseudoIds(text: string, map: RedactionMap): string`
- deps: none
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/agents/support/prompt.ts:115 — chokepoint enforcement: ticket message bodies passed through `redactForPrompt` before Gemini system prompt assembly
- shape: `buildSystemPrompt(deps: AgentDeps, history): Promise<string>` (uses `redactForPrompt`)
- deps: helpdesk | db
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/agents/support/types.ts:59 — `AgentDeps.redactionMap` threaded through loop + tools
- shape: `AgentDeps { … redactionMap: RedactionMap; … }`
- deps: none
- maturity: multideal richest

- donor: multideal/apps/web/src/server/observability/pii-scrub.ts — (cross-ref) cron/runner error logging uses `scrubErrorForLog`; not prompt redaction but adjacent PII hygiene in AI paths
- shape: `scrubErrorForLog(err): unknown` (used by `process-llm-jobs.ts:215`)
- deps: none
- maturity: multideal richest for AI-path observability scrub

## tool-use agent harness + registry
- donor: multideal/apps/web/src/server/ai/agents/support/agent.ts:88 — main agent loop `runSupportAgent(deps, input)`; JSON turn parse → confidence gate → Zod validate → `TOOL_IMPLS` dispatch → intervention log → terminal break
- shape: `runSupportAgent(deps: AgentDeps, input: SupportAgentRunInput): Promise<SupportAgentRunResult>`; `makeSupportAgentDeps(env, input): Promise<AgentDeps>`; constants `WRITE_TOOLS`, `TERMINAL_TOOLS`, `maxToolCalls` from `config.maxToolCalls`
- deps: db | helpdesk | notifications
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/agents/support/agent.ts:161 — loop controls: `while (runState.toolCallsMade < config.maxToolCalls)`; model fallback on transient/rate-limit; cost cap → `forceEscalate`; 2× JSON parse failure → escalate; max iterations without terminal → escalate
- shape: `parseTurn(text): AgentTurn | null`; `callWithFallback(provider, creds, prompt, model, fallbackModel, imageData?)`; `forceEscalate(deps, input, reason, meta)`
- deps: db | helpdesk
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/agents/support/tools/_registry.ts:34 — tool registry: `SUPPORT_TOOLS` array assembled into `TOOL_SCHEMAS` + `TOOL_IMPLS` maps keyed by `ToolName`
- shape: `SUPPORT_TOOLS` const array; `TOOL_SCHEMAS: Record<ToolName, z.ZodType>`; `TOOL_IMPLS: Record<ToolName, ToolImpl<any>>`; `SUPPORT_AGENT_NAME = 'support_agent'`; `type ToolName`
- deps: none
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/agents/support/tools/_types.ts:14 — per-tool contract: Zod input + `ToolImpl` receiving `AgentDeps`
- shape: `type ToolImpl<O> = (deps: AgentDeps, rawInput: unknown) => Promise<ToolResult<O>>`; `SupportTool<TName, TInput, TOutput> { name, inputSchema, impl }`; `ToolResult<T> = { ok: true, data } | { ok: false, error }`
- deps: none
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/agents/support/agent.ts:328 — terminal-tool handling: `TERMINAL_TOOLS = escalate_to_human | close_ticket | issue_refund`; breaks loop (refund only on `toolResult.ok`)
- shape: `TERMINAL_TOOLS: Set<ToolName>`; `toolToDecision(tool): 'auto'|'escalate'|'ask'|'propose'`
- deps: helpdesk
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/agents/support/agent.ts:246 — autonomy/threshold injection: write tools gated by `turn.confidence < config.aiConfidenceThreshold` → forced `escalate_to_human`
- shape: `WRITE_TOOLS: Set<ToolName>`; `config.aiConfidenceThreshold` from `system_config.support_ai_confidence_threshold`
- deps: db | helpdesk
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/agents/support/prompt.ts:41 — system prompt embeds autonomy matrix + config snippet (threshold, refund cap, SLA) — mechanism generic, values from DB
- shape: `buildSystemPrompt(deps, messages)`; `autonomyMatrix(locale, config)`; `configSnippet(config)`
- deps: db | helpdesk
- maturity: multideal richest

## the 4 kinds' rule files
- donor: multideal/apps/web/src/server/ai/kinds/deal-moderation.ts:178 — deal-moderation rules: prompt from `llm_queues.prompt` (queue `moderation`) OR `QUEUE_DEFAULT_PROMPTS.moderation` OR `DEFAULT_MODERATION_PROMPT` in `llm.ts:45`; inline Hebrew marketplace moderation criteria + `DECISION: APPROVE|FLAG` parser; app-domain (deal fields, `UNDER_REVIEW` guard, approve/flag writes)
- shape: `dealModerationKind`; `parseDecision(text)`; `DealModerationInput.systemPrompt`
- deps: db
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/agents/image-approval.ts:340 — image-approval rules: Hebrew prompt in `UploadImageModerationAgent.evaluate` (`PASS|FLAG|REJECT` + `SCORE` 0–1); used by `imageApprovalKind` queue path; separate English prompts in `GeminiImageApprovalAgent` for sync URL-review paths; app-domain (Multideal policy, `image_uploads` cascade)
- shape: `UploadImageModerationAgent.evaluate(imageBytes, mime)`; `ImageModerationDecision = 'PASS'|'FLAG'|'REJECT'`
- deps: uploads | db
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/kinds/translation.ts:113 — translation rules: prompt from `llm_queues` queue `translation` OR `QUEUE_DEFAULT_PROMPTS.translation`; JSON field output contract; app-domain (deal `title`/`description` Hebrew→English, `deal_translations` upsert)
- shape: `translationKind`; `extractJson(text)`; `QUEUE_DEFAULT_PROMPTS.translation`
- deps: db
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/agents/vendor-violation-flag.ts:230 — vendor-violation (admission) rules: `buildAdmissionPrompt(input)` inline criteria (`APPROVE|FLAG|REJECT` + rationale); deterministic fallbacks `deterministicAdmission`; separate `buildPrompt(signals)` for ongoing violation flagging; app-domain (vendor registration fields, `PENDING_FIRST_APPROVAL` guard)
- shape: `GeminiVendorViolationFlagAgent.review(input)`; `buildAdmissionPrompt(input: VendorAdmissionInput): string`; `parseAdmissionResponse(raw)`
- deps: db
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/queue-defaults.ts:3 — shared default prompt strings per `LlmQueueId` (`moderation`, `translation`; `support: ''`)
- shape: `QUEUE_DEFAULT_PROMPTS: Record<LlmQueueId, string>`
- deps: db
- maturity: multideal richest

## read-image / vision primitive
- donor: multideal/apps/web/src/server/security/safe-fetch-image.ts:88 — SSRF-safe HTTPS image fetch: private/reserved IP block, host allowlist (`imagedelivery.net`, site host), redirect cap 3, max 10 MB
- shape: `safeFetchImage(imageUrl, opts?: { siteUrl?, maxBytes?, signal? }): Promise<{ imageBytes: ArrayBuffer; mimeType: string }>`
- deps: none
- maturity: multideal richest for image-specific fetch; zync.is has generic `validateSafeOutboundUrl` (`packages/utils/src/ssrf-guard.ts:33`) without image MIME handling

- donor: multideal/apps/web/src/server/ai/agents/image-approval.ts:42 — vision primitive wrapper `fetchImageBytes(imageUrl)` delegates to `safeFetchImage` with `env.PUBLIC_SITE_URL`; used by sync `GeminiImageApprovalAgent` URL-review methods
- shape: `fetchImageBytes(imageUrl): Promise<{ imageBytes, mimeType }>` (private helper)
- deps: none
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/agents/support/tools/read-image.ts:13 — agent tool: loads attachment by id, ownership gate (`deps.parent`), fetches via `safeFetchImage`, base64-chunk encode, sets `deps.runState.pendingImageData` for next vision turn
- shape: `readImageTool: SupportTool<'read_image', { attachmentId: uuid }, { base64, mimeType }>`
- deps: helpdesk | uploads | db
- maturity: multideal richest (mechanism reusable; attachment parent check is multideal-wired)

- donor: multideal/apps/web/src/server/ai/agents/support/agent.ts:170 — agent loop vision path: when `runState.pendingImageData` set, calls `provider.generateTextWithImage` via `callWithFallback`
- shape: `callWithFallback(..., imageData?: { base64, mimeType })`; `pendingImageData` on `AgentRunState`
- deps: none
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/kinds/image-approval.ts:109 — queue kind vision path: bytes from R2 (`deps.r2Bucket.get`), not URL fetch — `UploadImageModerationAgent.evaluate(imageBytes, mime)` → `provider.generateTextWithImage`
- shape: `imageApprovalKind.loadInput` → R2 fetch; `UploadImageModerationAgent.evaluate`
- deps: uploads
- maturity: multideal richest

- donor: multideal/apps/web/src/server/ai/providers/types.ts:76 — provider vision seam: `LlmProvider.generateTextWithImage(creds, req: ImageRequest)` where `ImageRequest { model, prompt, imageBytes, mime, kind? }`
- shape: `generateTextWithImage`; `supportsImage: boolean` (gemini + anthropic true; openai-compat false)
- deps: none
- maturity: multideal richest
