AI

AI

Overview

HudAI is the Apple-side inference primitive in HudsonKit. It handles one model turn at a time — provider setup, credentials, streaming, typed tool calls, recoverable errors — behind a single HudAIClient. Swap providers by passing a different adapter.

Design rationale lives in the internal engineering spec at docs/specs/hud-ai-framework.md.

HudAIClient

Construct with a provider adapter and a credential source (typically a HudVault).

import HudsonAI
import HudsonUI

let client = HudAIClient(provider: HudAIProviders.Claude(), hudVault: vault)

let response = try await client.complete(
    HudAIRequest(
        messages: [.user("Summarize today's standup notes.")],
        system: "You are a terse assistant."
    )
)
print(response.text)
NameTypeDefaultDescription
providerHudAIProviderAdapterAnthropicHudAIAdapter()Vendor adapter.
modelString?adapter defaultDefault model id; per-request overrides.
vault / hudVaultHudAICredentialSource / HudVault—Key source. Raw keys are never accepted in requests.
defaultsHudAIDefaults—Temperature, max output tokens, cache policy, timeout.
routeDefaultHudAIRoute.local.local, .auto, or .paired(...). Paired is reserved and throws.
urlSessionURLSession.sharedInject for tests or custom transport.

Providers

Adapters live under the HudAIProviders namespace so call sites read like vendor selection:

HudAIProviders.Claude()      // Anthropic — implemented
HudAIProviders.Anthropic()   // alias of Claude
HudAIProviders.OpenAI()      // implemented
HudAIProviders.OpenRouter(appTitle: "MyApp", siteURL: url)  // implemented
HudAIProviders.Grok()        // stub — throws .unsupportedFeature

Each adapter declares a credentialKey read from the vault: anthropic_key, openai_key, openrouter_key, grok_key. OpenRouter expects namespaced model ids like openai/gpt-4o-mini.

Requests and messages

HudAIMessage carries a role (.user, .assistant, .tool) and HudAIContentParts (.text, .toolCall, .toolResult). Helpers: .user(_:), .assistant(_:), .toolResult(_:).

let request = HudAIRequest(
    messages: [.user("What's the weather in Berlin?")],
    system: "Be concise.",
    tools: [weatherTool],
    toolChoice: .auto,
    temperature: 0.2,
    maxOutputTokens: 512
)

Streaming

stream(_:) returns AsyncThrowingStream<HudAIStreamEvent, Error> of vendor-neutral events.

for try await event in client.stream(request) {
    switch event {
    case .textDelta(_, let text):       print(text, terminator: "")
    case .reasoningDelta(_, let text):  print("[think] \(text)")
    case .toolCallReady(let call):      handle(call)
    case .usage(let usage):             record(usage)
    case .completed(let response):      finish(response)
    case .failed(let error):            throw error
    default: break
    }
}

Other events: .started, .toolCallStarted, .toolCallInputDelta (partial JSON), .toolResultAccepted, .cancelled.

Tool calls

Tools take a JSON Schema plus a Swift Decodable input type. HudAI validates the model's input, so call sites work in typed values, not raw JSON.

struct WeatherInput: Decodable { let city: String; let units: String? }

let weatherTool = HudAIToolDefinition(
    name: "get_weather",
    description: "Look up current weather.",
    inputSchema: [ /* JSON Schema as [String: HudAIJSONValue] */ ],
    inputType: WeatherInput.self
)

// On .toolCallReady:
let input = try call.decodeInput(WeatherInput.self)
let text = try await fetchWeather(city: input.city, units: input.units)
let toolMessage = HudAIMessage.toolResult(
    HudAIToolResult(toolCallID: call.id, content: .text(text))
)
// Append to messages, call client.stream again to continue the turn.

Use HudAIToolDefinition.untyped(...) to skip the Swift type — input arrives as raw HudAIJSONValue.

Credentials via HudVault

HudVault is HudsonKit's keychain wrapper. Store keys with the credential keys above, then pass the vault to the client:

try vault.set("anthropic_key", value: Data(apiKey.utf8))
let client = HudAIClient(provider: HudAIProviders.Claude(), hudVault: vault)

If a key is missing or empty, requests fail fast with .credentialsMissing or .credentialsInvalid before any network call.

Errors

All failures surface as HudAIError. Each case carries the provider; rejection and rate-limit cases also include HTTP status and request id.

CaseWhen
.credentialsMissingVault has no value for the adapter's key.
.credentialsInvalidValue is empty or non-UTF8.
.networkUnavailableTransport-level failure.
.providerRejectedRequest4xx from the provider.
.rateLimited429 / quota exhaustion. Retryable.
.overloadedProvider shedding load. Retryable.
.timeoutExceeded defaults.timeout. Retryable.
.cancelledTask was cancelled.
.toolInputDecodeFailedTool input doesn't decode to declared type.
.unsupportedFeaturePath not implemented (e.g. Grok stub).
.pairingChannelUnavailable.paired route, not in v1.
.providerProtocolErrorStream produced unexpected payload.

error.isRetryable flags transient cases. error.httpStatus and error.providerRequestID extract details for logging.

Keeping model catalogs current

Hudson tracks @earendil-works/pi-ai through latest, without a root version override. Run bun run update:models at the repository root to refresh the runtime and lockfile. The lockfile records the tested installation; a frozen install alone does not fetch newer releases.

The web model endpoint reads the runtime registry for all supported providers. Copilot discovery narrows that registry to account-visible chat models when available. Settings and developer pickers refresh on window focus, without a Hudson model allowlist. Saved model preferences remain host-owned.

The adapter uses pi-ai's supported compat entrypoint for its existing stream API, requiring pi-ai 0.85.1 or newer.

For AI agents