# Polish troubleshooting

> Polish failure modes: RewriteFailure and PolishFailureReason, rate-limit Retry-After, timeout fallbacks to raw transcript, polishKeyInvalid and polishOutOfCredits persistence, history-row failure tooltips, and prewarm connection behavior.

- Repository: cartesia-ai/InkIt
- GitHub: https://github.com/cartesia-ai/InkIt
- Human docs: https://grok-wiki.com/public/docs/cartesia-ai-inkit-18975554254b
- Complete Markdown: https://grok-wiki.com/public/docs/cartesia-ai-inkit-18975554254b/llms-full.txt

## Source Files

- `InkIt/TranscriptRewriter.swift`
- `InkIt/AppCoordinator.swift`
- `InkIt/TranscriptHistoryStore.swift`
- `InkIt/SettingsStore.swift`
- `InkIt/LLMProvider.swift`

---

---
title: "Polish troubleshooting"
description: "Polish failure modes: RewriteFailure and PolishFailureReason, rate-limit Retry-After, timeout fallbacks to raw transcript, polishKeyInvalid and polishOutOfCredits persistence, history-row failure tooltips, and prewarm connection behavior."
---

Optional LLM polish runs in `AppCoordinator.correctedTranscript` after Cartesia STT returns a final transcript. `TranscriptRewriter` classifies provider errors into `RewriteFailure`; the coordinator maps those to persisted `PolishFailureReason` values, always pastes the raw transcript on failure, and records the outcome in `TranscriptHistoryStore`. Only invalid-key and out-of-credits failures set durable `UserDefaults` flags that drive Home and Settings UI.

<Note>
Polish failures never block dictation. Unlike Cartesia STT errors, they do not call `setError` or flash the notch HUD. The user always receives at least the raw ASR text.
</Note>

## Failure flow

```mermaid
sequenceDiagram
    participant User
    participant AppCoordinator
    participant TranscriptRewriter
    participant Provider as LLM provider
    participant History as TranscriptHistoryStore

    User->>AppCoordinator: Release hotkey
    AppCoordinator->>AppCoordinator: STT final transcript
    AppCoordinator->>TranscriptRewriter: rewriteWithoutContext(raw)
    TranscriptRewriter->>Provider: POST /chat/completions or /messages
    alt HTTP 2xx + valid response
        Provider-->>TranscriptRewriter: polished text
        TranscriptRewriter-->>AppCoordinator: .success
        AppCoordinator->>AppCoordinator: Clear polishKeyInvalid / polishOutOfCredits
        AppCoordinator->>History: polish=.polished, original=raw
    else Any failure
        Provider-->>TranscriptRewriter: error status or timeout
        TranscriptRewriter-->>AppCoordinator: .failure(RewriteFailure)
        AppCoordinator->>AppCoordinator: Map to PolishFailureReason
        opt invalidKey or outOfCredits
            AppCoordinator->>AppCoordinator: Set polishKeyInvalid or polishOutOfCredits
        end
        AppCoordinator->>History: polish=.failed, text=raw, failure=details
        AppCoordinator->>User: Paste raw transcript
    end
```

While polish runs, `DictationState` is `.rewriting` and the notch HUD shows **Polishing…**. On failure the state advances to `.pasting` with the raw text — there is no `.error` transition for polish alone.

## `RewriteFailure` and `PolishFailureReason`

`TranscriptRewriter` returns `Result<String, RewriteFailure>`. `AppCoordinator.polishResult` maps each case 1:1 into `TranscriptHistoryStore.PolishFailureReason` and wraps provider metadata in `PolishFailure`.

| `RewriteFailure` | Trigger | `PolishFailureReason` | Persists Home flag? | User surfacing |
|---|---|---|---|---|
| `rateLimited(retryAt:)` | HTTP 429 | `rateLimited` | No | History tooltip with live retry hint |
| `offline` | `URLError` (no route, DNS, connection lost) | `offline` | No | History tooltip |
| `timedOut` | `URLError.timedOut`, HTTP 408/504, or exceeds `rewriteTimeout` | `timedOut` | No | History tooltip |
| `invalidKey` | HTTP 401/403, or empty API key before request | `invalidKey` | Yes → `polishKeyInvalid` | Home card + Settings `keyBroken` |
| `outOfCredits` | HTTP 402 | `outOfCredits` | Yes → `polishOutOfCredits` | Home card |
| `serverError` | HTTP 5xx | `serverError` | No | History tooltip |
| `unknown` | JSON parse failure, empty response, length sanity reject | `unknown` | No | History tooltip |

The length sanity check rejects polish output when `cleaned.count > max(120, Int(Double(transcript.count) * 2.5) + 40)`, treating runaway model output as `unknown`.

### HTTP and network classification

`TranscriptRewriter.failure(forStatus:headers:)` maps status codes and parses `Retry-After`:

- **429** → `rateLimited(retryAt:)` — `retryAt` from header
- **401, 403** → `invalidKey`
- **402** → `outOfCredits`
- **408, 504** → `timedOut`
- **500–599** → `serverError`
- **Other** → `unknown`

`failure(forURLError:)` maps `URLError.timedOut` to `timedOut` and connectivity codes (`notConnectedToInternet`, `cannotConnectToHost`, `cannotFindHost`, `networkConnectionLost`, `dataNotAllowed`, `dnsLookupFailed`) to `offline`.

## Rate-limit `Retry-After`

On HTTP 429, `TranscriptRewriter.retryAt(from:)` parses the `Retry-After` header in two forms:

1. **Delta-seconds** — numeric string added to `Date()`
2. **HTTP-date** — `EEE, dd MMM yyyy HH:mm:ss zzz` (POSIX locale)

The parsed absolute time is stored on `PolishFailure.retryAt` and survives SwiftData persistence (`TranscriptRecordTests.testFailedRecordRoundTripsFailureDetails`).

`TranscriptHistoryRow.failureMessage` computes a live countdown at tooltip open time:

| Condition | Tooltip suffix |
|---|---|
| `retryAt` is nil | "Retry soon or switch provider." |
| `retryAt` ≤ 5 s away | "Try again now or switch provider." |
| ~1 minute away | "Try again in ~1 min or switch provider." |
| Longer | "Try again in ~N min or switch provider." |

Full rate-limit message pattern: `{Provider} rate limit — raw text pasted. {retry hint}`.

## Timeout fallbacks

Two independent timeout layers apply:

<ParamField body="LLMProvider.rewriteTimeout" type="TimeInterval">
Per-provider ceiling on `URLSessionConfiguration.timeoutIntervalForRequest` and `timeoutIntervalForResource`, also passed as the request `timeoutInterval`.
</ParamField>

| Provider | `rewriteTimeout` |
|---|---|
| `groq` | 1.0 s |
| `gemini`, `openai`, `anthropic` | 2.0 s |

Groq's 1.0 s ceiling is sized above observed p99 latency; a tighter value would downgrade healthy rewrites to raw text.

When a timeout fires — via `URLError.timedOut`, HTTP 408/504, or the session ceiling — `TranscriptRewriter` returns `.timedOut`. The coordinator pastes the raw transcript, records `polish=.failed` with `reason: .timedOut`, and does not set persistent flags. The latency popover labels the polish stage **Polish attempt** (not **Polish**) when `polishFailed` is true, reflecting time spent without a successful rewrite.

<Warning>
A timed-out polish still costs wall-clock time in `Latency.polishMs`. The total shown to the user includes transcribe + polish attempt, not paste.
</Warning>

## Persistent flags: `polishKeyInvalid` and `polishOutOfCredits`

Both flags live in `UserDefaults` and survive relaunch.

<ParamField body="polishKeyInvalid" type="Bool">
Set when the last polish attempt returned `invalidKey`. Cleared on the next successful polish, when `rewriteProvider` changes, or when `enablePolish(provider:)` runs.
</ParamField>

<ParamField body="polishOutOfCredits" type="Bool">
Set when the last polish attempt returned `outOfCredits`. Cleared on the next successful polish or provider change.
</ParamField>

`SettingsStore.polishIssue` surfaces a Home rail card only when **all** of the following hold:

- `correctionEnabled` is true
- `hasRewriteKey` is true (non-empty key for `rewriteProvider`)
- One of the flags above is set

Transient failures (rate limit, offline, timeout, server error, unknown) intentionally do **not** set these flags — they appear only in history.

### Settings and Home UI

| Flag | `polishUIState` | Home card | Settings behavior |
|---|---|---|---|
| `polishKeyInvalid` | `.keyBroken` | "Polish is paused" — invalid key | Amber card; master toggle disabled until key re-entered |
| `polishOutOfCredits` | `.on` (unchanged) | "Polish is paused" — out of credits | Polish stays enabled; CTA opens `rewriteProvider.billingURL` |

Re-entering a verified key in Settings (`LLMKeyValidator` → `.verified`) calls `enablePolish(provider:)`, which clears both flags and turns polish on from `.setup` or `.keyBroken`.

## History-row failure tooltips

Failed polish entries are stored with:

- `polish: .failed`
- `text`: raw ASR transcript (what was pasted)
- `original: nil` (no before/after diff)
- `failure`: `PolishFailure { reason, provider, retryAt? }`

The history row shows an amber `exclamationmark.triangle.fill` chip on hover. Clicking it opens a popover with `TranscriptHistoryRow.failureMessage(failure)`:

| `PolishFailureReason` | Message pattern |
|---|---|
| `rateLimited` | `{Provider} rate limit — raw text pasted. {retry hint}` |
| `offline` | "No internet — raw text pasted. Reconnect and re-dictate." |
| `timedOut` | "Polish timed out — raw text pasted. Re-dictate to retry." |
| `invalidKey` | "Invalid {Provider} API key — raw text pasted. Fix it in Settings." |
| `outOfCredits` | "Out of {Provider} credits — raw text pasted. Review your {Provider} plan to re-enable Polish." |
| `serverError` | `{Provider} server error — raw text pasted. Try again shortly." |
| `unknown` | "Polish failed — raw text pasted. Re-dictate to retry." |
| `failure` is nil (legacy) | "Polish failed — raw text pasted. Re-dictate to retry." |

Legacy entries without a stored `polish` field fall back to showing a polished indicator only when `original != nil`.

## Prewarm connection behavior

At hotkey press (`startDictation`), when polish is eligible, `AppCoordinator` builds a `TranscriptRewriter` and calls `prewarm()` before audio capture begins:

**Prewarm runs when:**

- Not routing to the onboarding Try It box (`routeToOnboardingBox == false`)
- `settings.correctionEnabled` is true
- API key for `settings.rewriteProvider` is non-empty

**Prewarm is skipped when:**

- Onboarding trial (never polishes)
- Polish disabled
- No provider key configured

`TranscriptRewriter.prewarm()` issues a `HEAD` request to `provider.endpoint` with a 2.5 s timeout. The response body and status are discarded — even 404/405 warms DNS + TCP + TLS. The **same** `URLSession` instance on that `TranscriptRewriter` is later reused for the real `POST`, so the pooled connection lands on the polish hot path.

The warmed instance is stored in `warmRewriter` and consumed once in `correctedTranscript`. If prewarm was skipped (e.g. settings changed mid-hold), a fresh `TranscriptRewriter` is constructed at release. `warmRewriter` is reset to `nil` on every `startDictation`.

<Info>
Prewarm overlaps connection setup with the time the user is still speaking, removing roughly one round trip from the post-release polish stage. It does not send the rewrite prompt or spend tokens.
</Info>

## Diagnostic workflow

<Steps>
<Step title="Confirm polish is enabled and keyed">
Open Settings → Polish. Verify `correctionEnabled` is on, a provider is selected, and the key field is populated. If `polishUIState` is `.keyBroken`, re-enter and verify the key.
</Step>

<Step title="Dictate and check what pasted">
On any polish failure, raw ASR text still pastes. Compare the pasted text against the history row — failed entries show raw text with no sparkles diff.
</Step>

<Step title="Inspect the history failure chip">
Hover the transcript row, click the amber warning triangle, and read the provider-specific reason. For rate limits, note the live retry countdown.
</Step>

<Step title="Check persistent vs transient">
If Home shows **Polish is paused**, fix the key (`polishKeyInvalid`) or billing (`polishOutOfCredits`). For transient errors (timeout, offline, 5xx, rate limit), re-dictate or switch provider — no flag is set.
</Step>

<Step title="Enable debug logging for HTTP detail">
Turn on `debugLoggingEnabled` in Settings. Polish HTTP status, classified `RewriteFailure`, and prewarm logs appear in `~/Library/Logs/InkIt-debug.log` with redacted API keys.
</Step>
</Steps>

### Expected signals by failure type

| Symptom | Likely cause | Fix |
|---|---|---|
| Raw text, no Home card, history shows rate-limit tooltip | Provider 429 | Wait for `Retry-After` hint or switch provider |
| Raw text, "Polish timed out" in history | Hung or slow provider | Re-dictate; consider Groq (1.0 s ceiling) or check network |
| Raw text, Home "invalid key" card | 401/403 from provider | Update key in Settings → Polish |
| Raw text, Home "out of credits" card | HTTP 402 | Open billing URL from Home CTA |
| Polished text with sparkles diff | Success | No action needed — flags cleared automatically |

## Comparison with STT failures

Polish and transcription failures use parallel classification enums but different surfacing rules:

| Aspect | STT (`STTFailure`) | Polish (`RewriteFailure`) |
|---|---|---|
| Blocks paste | Yes — session aborts | No — raw text always pastes |
| Notch HUD error | Yes (`setError`) | No |
| Persistent flags | `cartesiaKeyInvalid`, `cartesiaOutOfCredits` | `polishKeyInvalid`, `polishOutOfCredits` |
| Transient errors | Notch only | History tooltip only |
| Primary log | Notch + optional Home card | History row + optional Home card |

## Related pages

<CardGroup>
<Card title="Configure Polish" href="/configure-polish">
Enable polish, pick a provider, store keys, and interpret `PolishUIState`.
</Card>
<Card title="LLM providers reference" href="/llm-providers-reference">
Endpoints, default models, `rewriteTimeout` ceilings, and validation probes per `LLMProvider`.
</Card>
<Card title="Dictation pipeline" href="/dictation-pipeline">
End-to-end flow including polish placement, latency stages, and prewarm timing.
</Card>
<Card title="Settings reference" href="/settings-reference">
All `UserDefaults` keys including `polishKeyInvalid` and `polishOutOfCredits`.
</Card>
<Card title="STT troubleshooting" href="/stt-troubleshooting">
Cartesia transcription failures — parallel failure model with different surfacing rules.
</Card>
</CardGroup>
