diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e84ae2..8a74368 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,48 @@ recompiling. A breaking change waits for 2.0. (Before RC.1, `1.0.0-beta.N` and e beta.4 → RC.1 changes that can break a build are listed under *Changed — breaking (pre-GA)* in the RC.1 entry, and each entry's own "Beta caveats" apply to the betas.) +## 2.3.0 — GA — 2026-10-27 + +OpenAI's Decisions API as a fourth decider, and refusals in `Decision`. Additive only: code that +builds against 2.2.0 builds unchanged, and `LocalLMLabSDKInference` and `LocalLMLabSDKClaude` have +no source changes. + +### Added + +- **OpenAI Decisions in `LocalLMLabSDKRemote`.** `JevDecisionProvider` now speaks OpenAI's + `/v1/decisions` as well as Jev's wire format, chosen by `JevProviderConfig`'s new `wire` + (`DecisionWireFormat`: `.jev`, `.openAIDecisions`). The `.openAI(key:)` preset registers + `openai:gpt-6-luna` (fidelity `.native`). `.openAICompatibleDecisions(baseURL:auth:models:…)` is for + any other server that speaks the same API, such as Fireworks, Vercel AI Gateway or LocalAI. For a + server that token-scores a general model, pass `fidelity: .tokenScored(calibrated: false)`. + `probe(for:timeout:)` checks the key and model with `GET /v1/models`. +- **`OpenAIDecisionWire` in `LocalLMLabSDKCore`**, the codec for both directions, with OpenAI's + input limits (`DecisionLimits.openAIDecisions`) checked before anything is sent. +- **Refusals.** A backend can now decline a question: `Decision.refusals` lists the refused ids, and + `isRefused(_:)` checks one. A refused id has no entry in `answers`. Only OpenAI's Decisions API + refuses today; every other backend leaves `refusals` empty. `lab.decide` accepts a decision in + which each question is either answered or refused. `lab.evaluate` counts a refusal as a miss + (`QuestionScore.refused`) that adds no calibration sample. The Jev wire carries refusals as + `{"type":"refusal"}`. + +### Changed + +- **`Decision`'s JSON** gains `refusals`. JSON written by 2.2 and earlier still decodes, and a + decision without refusals encodes exactly as it did before. The new `Decision` and + `JevProviderConfig` initializers are overloads, not added defaulted parameters, so the existing + initializers are unchanged. + +### Checksums (SHA-256) + +``` +LocalLMLabSDKCore-2.3.0-GA.xcframework.zip 7821197d0cf5b4c92e36af5c537295e594afbb55d60d2f98b0773a3625403575 +LocalLMLabSDKClaude-2.3.0-GA.xcframework.zip 80e143412d0bd0538091b59ea050015d28828855809203ea51c1cd49675e8449 +LocalLMLabSDKInference-2.3.0-GA.xcframework.zip 63dc8bcba5e91e1324809ca412a23ebb0b9e04386361436eacc0be9fe6d4a8e0 +LocalLMLabSDKRemote-2.3.0-GA.xcframework.zip dae65d45bea196f6cd2538328fad1a0e33eceae5fac5ee965183706ac942daaf +``` + +--- + ## 2.2.0 — GA — 2026-10-22 Nemotron 3.5 support in `LocalLMLabSDKInference`; no API change (the public surface is identical to diff --git a/Components/Package.swift b/Components/Package.swift index 21107d2..2114b6c 100644 --- a/Components/Package.swift +++ b/Components/Package.swift @@ -24,17 +24,17 @@ struct SDKRelease { // or edit `defaultSDKVersion`. For a release not listed, add its entry (URL follows the pattern // below; checksum is the `.sha256` next to the zip on that GitHub release). Keep this in step // with `examples/model-switch/Package.swift`, which consumes Core through this package. -let defaultSDKVersion = "2.2.0-GA" +let defaultSDKVersion = "2.3.0-GA" let knownSDKReleases: [String: SDKRelease] = [ - "2.1.0-GA": SDKRelease( - url: "https://github.com/ancientcomputing/locallm/releases/download/v2.1.0-GA/LocalLMLabSDKCore-2.1.0-GA.xcframework.zip", - checksum: "8d541e06f81d1586c959d6159a259c759853c88b2c68385ab28de493af6c5776" - ), "2.2.0-GA": SDKRelease( url: "https://github.com/ancientcomputing/locallm/releases/download/v2.2.0-GA/LocalLMLabSDKCore-2.2.0-GA.xcframework.zip", checksum: "5efc40cae9f8a1bf53bfe03195c5365398bc9501b3e6b0feee3e5c67385537f4" ), + "2.3.0-GA": SDKRelease( + url: "https://github.com/ancientcomputing/locallm/releases/download/v2.3.0-GA/LocalLMLabSDKCore-2.3.0-GA.xcframework.zip", + checksum: "7821197d0cf5b4c92e36af5c537295e594afbb55d60d2f98b0773a3625403575" + ), ] func failManifest(_ message: String) -> Never { diff --git a/README.md b/README.md index fd78b21..5f79019 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ Notes: -1. The 2.2.0-GA release branch is at [https://github.com/ancientcomputing/locallm/tree/2.2.0-GA](https://github.com/ancientcomputing/locallm/tree/2.2.0-GA) +1. The 2.3.0-GA release branch is at [https://github.com/ancientcomputing/locallm/tree/2.3.0-GA](https://github.com/ancientcomputing/locallm/tree/2.3.0-GA) 2. The project website is at [https://thisbrain.ai/locallm/](https://thisbrain.ai/locallm/) # LocalLM Lab @@ -40,7 +40,7 @@ doesn't carry. **Product page:** [thisbrain.ai/locallm](https://thisbrain.ai/locallm) — or get the app directly: -[LocalLM Lab 2.2.0-GA (arm64 DMG)](https://github.com/ancientcomputing/locallm/releases/download/v2.2.0-GA/LocalLM.Lab-2.2.0-GA-arm64.dmg). +[LocalLM Lab 2.3.0-GA (arm64 DMG)](https://github.com/ancientcomputing/locallm/releases/download/v2.3.0-GA/LocalLM.Lab-2.3.0-GA-arm64.dmg). ## LocalLM Lab SDK diff --git a/docs/api-surface.md b/docs/api-surface.md index fb4a12b..54a4eee 100644 --- a/docs/api-surface.md +++ b/docs/api-surface.md @@ -2,7 +2,7 @@ > Machine-generated by `scripts/dump-public-api.sh` from the built `.swiftinterface`s. > Do not hand-edit. `sdk-guide.md` §12 is the *narrative* reference; this is the check. -> SDK version: `"2.2.0"` (the published `v2.2.0-GA` xcframeworks, built from dd96469) · generated 2026-10-22T20:25Z +> SDK version: `"2.3.0"` (the published `v2.3.0-GA` xcframeworks, built from 9b45072) · generated 2026-10-27T19:05Z ## LocalLMLabSDKCore @@ -801,15 +801,18 @@ public var modelID: ModelID public var fidelity: DecisionFidelity public var latency: Duration public var usage: DecisionUsage? +public var refusals: Set public init(answers: [String : DecisionAnswer], modelID: ModelID, fidelity: DecisionFidelity, latency: Duration, usage: DecisionUsage? = nil) +public init(answers: [String : DecisionAnswer], modelID: ModelID, fidelity: DecisionFidelity, latency: Duration, usage: DecisionUsage? = nil, refusals: Set) +public func isRefused(_ id: String) -> Bool +public init(from decoder: any Decoder) throws +public func encode(to encoder: any Encoder) throws public func noul(_ id: String) -> Double? public func choice(_ id: String) -> ChoiceAnswer? public func score(_ id: String) -> ScoreAnswer? public static func == (a: Decision, b: Decision) -> Bool -public func encode(to encoder: any Encoder) throws public func hash(into hasher: inout Hasher) public var hashValue: Int -public init(from decoder: any Decoder) throws public enum DecisionError : Error, LocalizedError, Sendable, Equatable case invalidRequest(String) case noRoute(RouteName) @@ -936,6 +939,7 @@ public var answers: [String : DecisionAnswer] public var usage: DecisionUsage? public var fidelity: DecisionFidelity? public var errorMessage: String? +public var refusals: Set public static func encodeRequest(_ request: DecisionRequest, model: String) throws -> Data public static func decodeRequest(_ data: Data) throws -> JevWire.Request public static func encodeResponse(_ decision: Decision, model: String, request: DecisionRequest) throws -> Data @@ -949,6 +953,7 @@ public var id: String public var kind: DecisionCalibration.Sample.Kind public var marked: Int public var correct: Int +public var refused: Int public var accuracy: Double public var meanConfidenceWhenRight: Double? public var meanConfidenceWhenWrong: Double? @@ -1613,6 +1618,22 @@ case keepWarm([RouteName]) public static func == (a: ModelResidency, b: ModelResidency) -> Bool public func encode(to encoder: any Encoder) throws public init(from decoder: any Decoder) throws +public enum DecisionWireFormat : String, Sendable, Hashable, Codable +case jev +case openAIDecisions +public init?(rawValue: String) +public var rawValue: String +public static let openAIDecisions: DecisionLimits +public enum OpenAIDecisionWire +public static let maxTextCharacters: Int +public static let maxJSONDepth: Int +public static let maxJSONValues: Int +public static let maxJSONBytes: Int +public static func encodeRequest(_ request: DecisionRequest, model: String) throws -> Data +public static func decodeRequest(_ data: Data) throws -> JevWire.Request +public static func encodeResponse(_ decision: Decision, model: String, request: DecisionRequest) throws -> Data +public static func decodeResponse(_ data: Data, request: DecisionRequest) throws -> JevWire.Response +public static func errorMessage(in data: Data) -> String? public struct PCCModelProvider : ModelProvider public static var scheme: String public init() @@ -2553,10 +2574,14 @@ public var fidelity: DecisionFidelity public var limits: DecisionLimits public var responseLimits: RemoteResponseLimits public var timeout: TimeInterval +public var wire: DecisionWireFormat public init(scheme: String, displayName: String, endpoint: URL, auth: RemoteProviderConfig.Auth, models: [String] = [], allowArbitraryModelIDs: Bool = true, fidelity: DecisionFidelity, limits: DecisionLimits = .jev, responseLimits: RemoteResponseLimits = .default, timeout: TimeInterval = 60) +public init(scheme: String, displayName: String, endpoint: URL, auth: RemoteProviderConfig.Auth, models: [String] = [], allowArbitraryModelIDs: Bool = true, fidelity: DecisionFidelity, limits: DecisionLimits = .jev, responseLimits: RemoteResponseLimits = .default, timeout: TimeInterval = 60, wire: DecisionWireFormat) public static func openRouter(key: String) -> JevProviderConfig public static func featherless(key: String) -> JevProviderConfig public static func featherlessDemo() -> JevProviderConfig +public static func openAI(key: String) -> JevProviderConfig +public static func openAICompatibleDecisions(baseURL: URL, auth: RemoteProviderConfig.Auth, models: [String], limits: DecisionLimits = .openAIDecisions, scheme: String = "openaicompat", fidelity: DecisionFidelity = .native) -> JevProviderConfig public func validated() throws -> JevProviderConfig public static func == (a: JevProviderConfig, b: JevProviderConfig) -> Bool public struct JevDecisionProvider : DecisionProvider @@ -2567,7 +2592,9 @@ public var limits: DecisionLimits public func fidelity(for id: ModelID) -> DecisionFidelity public func availability(for id: ModelID) -> ModelAvailability public func decide(_ request: DecisionRequest, using id: ModelID) async throws -> Decision +public func probe(for id: ModelID, timeout: Duration = .seconds(8)) async -> ModelAvailability public static func requestBody(model: String, request: DecisionRequest) throws -> Data +public static func requestBody(model: String, request: DecisionRequest, wire: DecisionWireFormat) throws -> Data public static func openAI(apiKey: String, models: [RemoteModel] = []) -> RemoteProviderConfig public static func anthropic(apiKey: String, models: [RemoteModel] = []) -> RemoteProviderConfig public static func openAIResponses(apiKey: String, models: [RemoteModel] = []) -> RemoteProviderConfig diff --git a/docs/sdk-guide.md b/docs/sdk-guide.md index 0673c18..188e2f2 100644 --- a/docs/sdk-guide.md +++ b/docs/sdk-guide.md @@ -9,7 +9,7 @@ below for the working reference apps this guide is drawn from. Requires macOS 26+ on Apple Silicon, Swift 6 tools. -**Status note**: this guide describes the API as of `2.2.0`. `2.0.0` was a one-time breaking release; +**Status note**: this guide describes the API as of `2.3.0`. `2.0.0` was a one-time breaking release; every 2.x release after it is source compatible with it (see [§9](#9-scope-and-boundaries-what-core-doesnt-do) for the compatibility policy). Coming from 1.x: [`migrating-to-2.0.md`](migrating-to-2.0.md); from `0.8.x`: [`migrating-to-1.0.md`](migrating-to-1.0.md) first. New in 2.0: per-server trust and tool @@ -1895,13 +1895,14 @@ is [`examples/model-switch`](../examples/model-switch/).* A **decision model** (a *decider*) takes one input and a few typed questions, and answers each with one of the options you allowed plus a probability for every option. It writes no text. TypeSafe's **Jev** is the original; the SDK uses Jev's vocabulary and asks the same questions of -three backends: +four backends: | Decider | Runs | Module | OS | `Decision.fidelity` | |---|---|---|---|---| | **OpenJev** (`OpenJevDecisionProvider`) | On the Mac, on an MLX model | Inference | macOS 27 | `.tokenScored(calibrated:)` | | **TypeSafe Jev** via OpenRouter (`JevDecisionProvider(.openRouter(key:))`) | Hosted | Remote | macOS 26+ | `.native` | | **Featherless Simple Jev** (`.featherless(key:)`, `.featherlessDemo()`) | Hosted | Remote | macOS 26+ | `.tokenScored(calibrated: false)` | +| **OpenAI Decisions** (`JevDecisionProvider(.openAI(key:))`, `openai:gpt-6-luna`; SDK 2.3) | Hosted | Remote | macOS 26+ | `.native` | `lab.decide` and everything else in this section except OpenJev is in Core and works on macOS 26. @@ -1945,8 +1946,13 @@ d.noul("refund") // Double: probability of yes d.score("urgency") // ScoreAnswer: .score 1.6 (expected level, 0-based), .level, .probabilities, .legend d.fidelity // .native or .tokenScored(calibrated:) d.modelID, d.latency, d.usage // usage: tokens and cost, from hosted backends +d.isRefused("refund") // true if the backend declined that question (OpenAI Decisions only) ``` +- **A backend can decline a question** (SDK 2.3). Only OpenAI's Decisions API does this today. A + refused id is in `d.refusals` and has no answer, so check `d.isRefused(id)` before reading it + and take the cautious path. `lab.evaluate` counts a refusal as a miss. + - **Question kinds** use Jev's names: `noul` (yes / no; the answer is P(yes)), `choice` (one of several, each with an optional description), `score` (an ordered scale described in words). Ids are what your code reads; `instructions` is what the model reads. @@ -2031,6 +2037,26 @@ choice options keep your order on the wire, JSON `state` is sent as JSON, and `d the tokens and cost. Like other remote providers, it's off by default and your UI should say where inputs are sent. +**OpenAI's Decisions API (SDK 2.3)** uses the same provider with a different wire format, +`JevProviderConfig`'s `wire: .openAIDecisions` (the other presets use `.jev`): + +```swift +try lab.models.register(decision: JevDecisionProvider(.openAI(key: key))) +lab.models.route(decision: "support", to: ModelID("openai:gpt-6-luna")!) +``` + +- `.openAI(key:)` offers `openai:gpt-6-luna` only, with `.native` fidelity: the probabilities are + OpenAI's own. Its limits are `DecisionLimits.openAIDecisions` (400 questions, 255 choice + options, 10 score levels), checked before anything is sent. +- `.openAICompatibleDecisions(baseURL:auth:models:)` is for any other server that speaks the same + API (Fireworks, Vercel AI Gateway, LocalAI): requests go to `baseURL` + `/decisions`. For a + server that token-scores a general model, pass `fidelity: .tokenScored(calibrated: false)`. +- `probe(for:timeout:)` checks the key and model with one `GET …/models`, never a decision: + `.needsCredential` for a rejected key, `.unsupportedModel` for a model the server doesn't list, + `.providerError` when it's unreachable or slow (8 seconds by default). On the Jev wire it + returns the local check only. +- It's the one backend that can decline a question: check `d.isRefused(id)` (above). + ### A decider and your chat model: one model or two The pattern the SDK is built for is two models per feature: a decider for the typed choices (does diff --git a/examples/README.md b/examples/README.md index db0aa08..ce038ad 100644 --- a/examples/README.md +++ b/examples/README.md @@ -22,8 +22,8 @@ Code samples for LocalLM Lab, split by which feature they use. | [security-demo/](security-demo/) | LocalLM Lab SDK (Core **+ Remote** + `Components`) | Tool authorization: a "Security" panel whose two controls are `Sequence.limited(toMaxImpact:)` (which tools the model sees) and `ConfirmingToolAuthorizer` + `Components.ToolConfirmationPresenter` (whether a call runs or asks first). A frontier model against a real Calendar and a real Todoist MCP server. | | [vistanova/](vistanova/) | LocalLM Lab SDK (Core **+ Inference**) | A tiny local search engine: a SwiftUI app that runs web search through a Tavily MCP server on one local model and summarizes on another — by default a downloaded MLX model **shipped pinned to an exact commit** (`MLXModelProvider(pinnedRevisions:)`), so a new upstream version is never picked up silently. Also shows a hand-written `Tool` over an MCP tool, and the defenses a small model needs: verify from `session.events` that it really called the tool, probe `capabilityProbe` before trusting structured output. | | [aiql/](aiql/) | LocalLM Lab SDK (Core **+ Inference**) | "Ask your data": a SwiftUI app that pulls an MCP-fronted dataset and writes the spreadsheet you asked for — `FileBackedTool` + the data verbs (`jsonToCsv` / `filterRows` / `sortRows` / …, guide §8b) do every row-level step so a local MLX model can't fabricate a value. Sandboxed; MCP OAuth via the `aiql://` URL scheme. | -| [jevdk/](jevdk/) | LocalLM Lab SDK (Core **+ Inference + Remote**), **2.0** | The decision API (Jev): a playground for the yes/no, choice and score questions an app asks a decision model. Runs them on a local MLX model (`OpenJevDecisionProvider`) and on hosted Jev (TypeSafe via OpenRouter, Featherless) side by side, scores a batch against your marked answers, fits calibration, appends runs to a results CSV for comparing models, and exports the tested questions for your app (`DecisionQuestionSet`). Comes with [a developer's guide](jevdk/GUIDE.md). | -| [jev-serve/](jev-serve/) | LocalLM Lab SDK (Core **+ Inference**), **2.0** | Hosted Jev's HTTP API (OpenRouter's `/api/alpha/decisions`, Featherless's `/v1/classifier`) answered by OpenJev on a local MLX model: code that calls hosted Jev, in any language, switches to private on-device decisions by changing its base URL. Serves the setup tested in JevDK (model pinned to its version, system instructions, calibration) from JevDK's exported config; downloads the model if needed; loopback, optional token. | +| [jevdk/](jevdk/) | LocalLM Lab SDK (Core **+ Inference + Remote**), **2.3** | The decision API (Jev): a playground for the yes/no, choice and score questions an app asks a decision model. Runs them on a local MLX model (`OpenJevDecisionProvider`) and on hosted deciders (TypeSafe via OpenRouter, Featherless, OpenAI Decisions) side by side, scores a batch against your marked answers, fits calibration, appends runs to a results CSV for comparing models, and exports the tested questions for your app (`DecisionQuestionSet`). Comes with [a developer's guide](jevdk/GUIDE.md). | +| [jev-serve/](jev-serve/) | LocalLM Lab SDK (Core **+ Inference**), **2.3** (through `../jevdk`) | Hosted Jev's HTTP API (OpenRouter's `/api/alpha/decisions`, Featherless's `/v1/classifier`) answered by OpenJev on a local MLX model: code that calls hosted Jev, in any language, switches to private on-device decisions by changing its base URL. Serves the setup tested in JevDK (model pinned to its version, system instructions, calibration) from JevDK's exported config; downloads the model if needed; loopback, optional token. | | [mcp-chat/](mcp-chat/) | LocalLM Lab SDK (Core **+ Inference** + `Components` + `MCPAppsHost`), **2.0** | MCP Apps in a chat: a local MLX model (or Apple's on-device model) whose tool calls show the server's interactive widget inline in the conversation — Todoist's task list, a system monitor. The widget's own calls go through the session, so they meet the same per-server trust / approval as the model's. Streaming replies, reasoning split out, conversations that survive relaunch. Sandboxed `.app`. | **Path A vs Path B**, for the four Core-based examples above: two ways to turn a connector or MCP @@ -59,7 +59,7 @@ advanced model-layer features (Private Cloud Compute, open-weight/MLX, Claude, o need it — but it isn't universal: [`os-matrix/`](os-matrix/) is built and tested to run unchanged on **macOS 26** too (see its README). -The examples on this `2.2.0-GA` branch build against SDK **`2.2.0-GA`**; the ones on `main` +The examples on this `2.3.0-GA` branch build against SDK **`2.3.0-GA`**; the ones on `main` build against the latest stable release. No environment variable is needed for either. ### What each example needs @@ -158,7 +158,7 @@ resolution doesn't inherit shell environment variables. ```bash cd examples/repo-qa -LOCALLM_SDK_VERSION=2.1.0-GA \ +LOCALLM_SDK_VERSION=2.2.0-GA \ DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \ swift run RepoQA facebook/react ``` @@ -171,13 +171,13 @@ previous one. (`code-buddy/sample-workspace/Package.swift` is not one of these dependency-free fixture for the code-buddy walkthrough, not an SDK consumer.) ```swift -let defaultSDKVersion = "2.2.0-GA" +let defaultSDKVersion = "2.3.0-GA" let knownSDKReleases: [String: SDKRelease] = [ - "2.1.0-GA": SDKRelease(url: "…/v2.1.0-GA/LocalLMLabSDKCore-2.1.0-GA.xcframework.zip", - checksum: "8d541e06…"), "2.2.0-GA": SDKRelease(url: "…/v2.2.0-GA/LocalLMLabSDKCore-2.2.0-GA.xcframework.zip", checksum: "5efc40ca…"), + "2.3.0-GA": SDKRelease(url: "…/v2.3.0-GA/LocalLMLabSDKCore-2.3.0-GA.xcframework.zip", + checksum: "7821197d…"), ] ``` @@ -188,10 +188,10 @@ every module ships its own checksum (`Core`, `Inference`, `Remote`, and `Claude` distinct `.xcframework.zip.sha256`; don't reuse Core's for another module): ```bash -curl -sL https://github.com/ancientcomputing/locallm/releases/download/v2.2.0-GA/LocalLMLabSDKCore-2.2.0-GA.xcframework.zip.sha256 -# → 5efc40cae9f8a1bf53bfe03195c5365398bc9501b3e6b0feee3e5c67385537f4 -curl -sL https://github.com/ancientcomputing/locallm/releases/download/v2.2.0-GA/LocalLMLabSDKClaude-2.2.0-GA.xcframework.zip.sha256 -# → 07e2ff01accf582f71fecab254bdae2e8fb46acc8296ef6ab1c8d84cd9e97a04 +curl -sL https://github.com/ancientcomputing/locallm/releases/download/v2.3.0-GA/LocalLMLabSDKCore-2.3.0-GA.xcframework.zip.sha256 +# → 7821197d0cf5b4c92e36af5c537295e594afbb55d60d2f98b0773a3625403575 +curl -sL https://github.com/ancientcomputing/locallm/releases/download/v2.3.0-GA/LocalLMLabSDKClaude-2.3.0-GA.xcframework.zip.sha256 +# → 80e143412d0bd0538091b59ea050015d28828855809203ea51c1cd49675e8449 ``` Then point `defaultSDKVersion` at it (works everywhere, Xcode included) or pass diff --git a/examples/aiql/Package.swift b/examples/aiql/Package.swift index deecb21..5f39db9 100644 --- a/examples/aiql/Package.swift +++ b/examples/aiql/Package.swift @@ -31,21 +31,21 @@ struct SDKRelease { // uses. `knownSDKReleases` carries this plus the previous release. Build against another // published version: set LOCALLM_SDK_VERSION in your shell (works for `swift build` / CI, NOT // inside Xcode), or edit `defaultSDKVersion` here. -let defaultSDKVersion = "2.2.0-GA" +let defaultSDKVersion = "2.3.0-GA" let knownSDKReleases: [String: SDKRelease] = [ - "2.1.0-GA": SDKRelease( - coreURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.1.0-GA/LocalLMLabSDKCore-2.1.0-GA.xcframework.zip", - coreChecksum: "8d541e06f81d1586c959d6159a259c759853c88b2c68385ab28de493af6c5776", - inferenceURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.1.0-GA/LocalLMLabSDKInference-2.1.0-GA.xcframework.zip", - inferenceChecksum: "77d3d1a1bae9df6de5955dfa945242c177cb129b4896c95befb81db6c923656f" - ), "2.2.0-GA": SDKRelease( coreURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.2.0-GA/LocalLMLabSDKCore-2.2.0-GA.xcframework.zip", coreChecksum: "5efc40cae9f8a1bf53bfe03195c5365398bc9501b3e6b0feee3e5c67385537f4", inferenceURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.2.0-GA/LocalLMLabSDKInference-2.2.0-GA.xcframework.zip", inferenceChecksum: "afd427fb741a820f55bc0a384850ae6404608f5ad73d185a583a89e8f0d61a61" ), + "2.3.0-GA": SDKRelease( + coreURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.3.0-GA/LocalLMLabSDKCore-2.3.0-GA.xcframework.zip", + coreChecksum: "7821197d0cf5b4c92e36af5c537295e594afbb55d60d2f98b0773a3625403575", + inferenceURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.3.0-GA/LocalLMLabSDKInference-2.3.0-GA.xcframework.zip", + inferenceChecksum: "63dc8bcba5e91e1324809ca412a23ebb0b9e04386361436eacc0be9fe6d4a8e0" + ), ] func failManifest(_ message: String) -> Never { diff --git a/examples/aiql/README.md b/examples/aiql/README.md index 3ead888..1a6d62f 100644 --- a/examples/aiql/README.md +++ b/examples/aiql/README.md @@ -103,7 +103,7 @@ export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer ``` Skip it if `xcode-select -p` already points at Xcode 27 (adjust the path if yours lives elsewhere). Leaves your system default alone; lasts only for the current terminal (re-run it in each new one, -or add it to your `~/.zshrc`). `Package.swift` builds against SDK `2.2.0-GA` with no further +or add it to your `~/.zshrc`). `Package.swift` builds against SDK `2.3.0-GA` with no further setup (AIQL needs it: it uses `lab.mcp.setFileBackedOutput`, new in 2.0) — it links **two** binaries, `LocalLMLabSDKCore.xcframework` and `LocalLMLabSDKInference.xcframework` (the MLX runtime), from that one GitHub Release. `export LOCALLM_SDK_VERSION=` to pin a different published release. **No Metal Toolchain @@ -163,9 +163,51 @@ open dist/AIQL.app ``` (`DEVELOPER_DIR` and `LOCALLM_SDK_VERSION` come from step 2.) The model download lands in the -app's sandbox container at -`~/Library/Containers/lab.locallm.sdk.reference.aiql/Data/Library/Caches/huggingface/` (not -`~/.cache`) — swift-huggingface redirects it automatically for a sandboxed app. +app's sandbox container, not `~/.cache` — see [Where the model is stored](#where-the-model-is-stored). + +## Where the model is stored + +AIQL is always App-Sandboxed, so swift-huggingface puts the Hugging Face cache in the app's own +container instead of `~/.cache/huggingface`: + +``` +~/Library/Containers/lab.locallm.sdk.reference.aiql/Data/Library/Caches/huggingface/hub/ + models--mlx-community--Qwen3-14B-4bit/ + snapshots// # config.json, *.safetensors, tokenizer… + blobs/ # the actual bytes (snapshot files symlink here) +``` + +- The container is named after the bundle ID, `lab.locallm.sdk.reference.aiql`. The Xcode Run + build and `dist/AIQL.app` share it, so one download serves both. +- Nothing else shares it. A copy in `~/.cache/huggingface/hub` (unsandboxed examples, `swift run`) + or in another example's container isn't used, so AIQL downloads its own (~8 GB for the default). +- To look at it in Finder: **Go ▸ Go to Folder…** (⇧⌘G) and paste the path. If macOS asks whether + Finder or Terminal may access another app's data, allow it. + +### Removing the model + +Quit AIQL first, then delete the model's folder: + +```bash +rm -rf ~/Library/Containers/lab.locallm.sdk.reference.aiql/Data/Library/Caches/huggingface/hub/models--mlx-community--Qwen3-14B-4bit +``` + +The next Go downloads it again, at the same version: the default model is pinned in the app, and a +model you typed is pinned in `mlx-pins.json`, kept outside the cache (in the container's +`Data/Library/Application Support/lab.locallm.sdk.reference.aiql/LocalLMLab/`). Each model you +typed has its own `models--…` folder next to the default one; remove it the same way. If a download +was interrupted, the folder may hold only the small files (config, tokenizer); removing it makes the +next run start the download from scratch. + +To start completely fresh — models, pins and the remembered output folder — quit the app and remove +the whole container. macOS creates a new one on the next launch: + +```bash +rm -rf ~/Library/Containers/lab.locallm.sdk.reference.aiql +``` + +If `rm` says *Operation not permitted*, macOS is protecting the app's data: allow Terminal when it +asks, or delete the folder in Finder instead. ## Try it diff --git a/examples/code-buddy/Package.swift b/examples/code-buddy/Package.swift index 85f25f2..9b968c6 100644 --- a/examples/code-buddy/Package.swift +++ b/examples/code-buddy/Package.swift @@ -25,21 +25,21 @@ struct SDKRelease { // `swift build` / CI, NOT inside Xcode), or edit `defaultSDKVersion` here. For a // release not listed, add its entry (URL + the `.sha256` next to the zip on the // GitHub release) or just replace the strings in place. -let defaultSDKVersion = "2.2.0-GA" +let defaultSDKVersion = "2.3.0-GA" let knownSDKReleases: [String: SDKRelease] = [ - "2.1.0-GA": SDKRelease( - coreURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.1.0-GA/LocalLMLabSDKCore-2.1.0-GA.xcframework.zip", - coreChecksum: "8d541e06f81d1586c959d6159a259c759853c88b2c68385ab28de493af6c5776", - inferenceURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.1.0-GA/LocalLMLabSDKInference-2.1.0-GA.xcframework.zip", - inferenceChecksum: "77d3d1a1bae9df6de5955dfa945242c177cb129b4896c95befb81db6c923656f" - ), "2.2.0-GA": SDKRelease( coreURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.2.0-GA/LocalLMLabSDKCore-2.2.0-GA.xcframework.zip", coreChecksum: "5efc40cae9f8a1bf53bfe03195c5365398bc9501b3e6b0feee3e5c67385537f4", inferenceURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.2.0-GA/LocalLMLabSDKInference-2.2.0-GA.xcframework.zip", inferenceChecksum: "afd427fb741a820f55bc0a384850ae6404608f5ad73d185a583a89e8f0d61a61" ), + "2.3.0-GA": SDKRelease( + coreURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.3.0-GA/LocalLMLabSDKCore-2.3.0-GA.xcframework.zip", + coreChecksum: "7821197d0cf5b4c92e36af5c537295e594afbb55d60d2f98b0773a3625403575", + inferenceURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.3.0-GA/LocalLMLabSDKInference-2.3.0-GA.xcframework.zip", + inferenceChecksum: "63dc8bcba5e91e1324809ca412a23ebb0b9e04386361436eacc0be9fe6d4a8e0" + ), ] func failManifest(_ message: String) -> Never { diff --git a/examples/components-updates-demo/README.md b/examples/components-updates-demo/README.md index ea9a306..17362d4 100644 --- a/examples/components-updates-demo/README.md +++ b/examples/components-updates-demo/README.md @@ -25,9 +25,9 @@ Requires macOS 27+ on Apple Silicon (Xcode 27 to build). No permissions, no sign ## Getting the SDK -This branch tracks `2.2.0-GA`. Nothing to download by hand — `Package.swift` depends on the sibling +This branch tracks `2.3.0-GA`. Nothing to download by hand — `Package.swift` depends on the sibling [`Components`](../../Components/) package, which resolves `LocalLMLabSDKCore` as a binary dependency and builds -against `2.2.0-GA` by default. Set `LOCALLM_SDK_VERSION` in a shell (not Xcode) to pin another published +against `2.3.0-GA` by default. Set `LOCALLM_SDK_VERSION` in a shell (not Xcode) to pin another published release — see [`../README.md`](../README.md#building--running-an-sdk-example). ## Build and run diff --git a/examples/jev-serve/Package.swift b/examples/jev-serve/Package.swift index 342bcbd..05256f3 100644 --- a/examples/jev-serve/Package.swift +++ b/examples/jev-serve/Package.swift @@ -15,9 +15,9 @@ import PackageDescription // ../jevdk, which declares them as binaryTargets on a GitHub Release and vends them as products. // This manifest must NOT declare its own: two packages declaring a binary target of the same name // is a hard SwiftPM error. So jev-serve builds against whichever SDK version JevDK's -// `defaultSDKVersion` / LOCALLM_SDK_VERSION selects (2.0 releases only). +// `defaultSDKVersion` / LOCALLM_SDK_VERSION selects (2.3 and later, as JevDK's table lists). // -// Requires SDK 2.0.0 or later, macOS 27 + Xcode 27. No Metal Toolchain needed — the prebuilt +// Requires SDK 2.3.0 or later, macOS 27 + Xcode 27. No Metal Toolchain needed — the prebuilt // Inference xcframework bundles the compiled shaders. let package = Package( name: "JevServe", diff --git a/examples/jevdk/GUIDE.md b/examples/jevdk/GUIDE.md index 9a10304..70c847a 100644 --- a/examples/jevdk/GUIDE.md +++ b/examples/jevdk/GUIDE.md @@ -175,6 +175,9 @@ Wording rules that held up in testing: careful wordings on a small model. - **One thing per question.** "Is the customer angry or threatening to leave?" mixes two; split it if they matter separately. +- **Give the question a way out.** If the input might not say, add an option for that + ("unclear: the message doesn't say"). A decider has to pick one of your options, so without + one it puts its guess on the nearest real answer (see [Step 2](#step-2-try-single-inputs)). ### Step 2. Try single inputs @@ -193,6 +196,29 @@ What to look at: Use this step to shape questions. Use the next steps to trust them. +**When the input can't answer the question.** Ask "Which religion does the author of this +message follow?" (Hindu / Muslim / Christian / None) about "Hi, I'm Priya. Can you help me reset +my password? Thanks!" Nothing in the message says, and deciders handle that differently: + +- **TypeSafe's Jev** picked **None**: 77% for None, 23% for Hindu, with a reported confidence of + 69%. In code that reads exactly like "this person has no religion"; only the hedging hints + that it's a guess. +- **OpenAI's Decisions API** **refused**: no answer and no probabilities. JevDK shows + **Refused**; in your app the question is in `Decision.refusals` and `d.isRefused("religion")` + is true. In the batch grid a refusal counts as a miss. +- **A local decider** always picks one of your options, like TypeSafe. + +Don't count on a refusal: OpenAI declines some questions (such as guessing someone's religion +from their name), and the other deciders never do. Ask only what the input can answer, give the +question an "unclear" option when it might not, and treat a refusal and an unsure answer the +same way in code: + +```swift +if d.isRefused("team") || (d.choice("team")?.confidence ?? 0) < 0.8 { + // ask the user, escalate, or take the safe path +} +``` + ### Step 3. Build your answer set Collect **50 to 150 real inputs** like the ones your app will see. Real is better than invented: @@ -254,8 +280,8 @@ set can tell you. JevDK doesn't keep a leaderboard on purpose. 1. **Models** (toolbar): download a few candidates. Small dense instruct models suit deciding; mixture-of-experts models are flagged because their probabilities were unstable in testing. -2. **Backends** (toolbar): optionally add a hosted decider (Featherless, or TypeSafe on - OpenRouter) as a reference point. +2. **Backends** (toolbar): optionally add a hosted decider (Featherless, TypeSafe on + OpenRouter, or OpenAI) as a reference point. 3. Run the batch with model A, then **Results CSV** (Batch toolbar) → append. Switch to model B, run, append. 4. Open the CSV in Numbers or Excel. A pivot table of `correct` by `model` and `question` is per-question accuracy; `question_ms` and `decision_ms` are speed. @@ -449,9 +475,11 @@ Sixteen messages are enough to show the method, not to choose a model. Use 50 or - **Model version:** the exact Hugging Face commit of a model (shown as e.g. `4dcb3d1`). Exports record it, so what ships is what you tested. - **Backend:** where the decider runs: locally (OpenJev on this Mac) or hosted (Featherless, - TypeSafe). + TypeSafe, OpenAI). - **System instructions / wrapper:** the text that frames every question for a local model. - **Calibration / temperature:** a per-question-type adjustment that makes confidence match how often the model is right. +- **Refusal:** a question the decider declined to answer (only OpenAI's Decisions API does + this). No answer and no probabilities; your app sees it in `Decision.refusals`. - **Batch:** the same questions asked about many inputs at once. - **Results CSV:** the file JevDK appends each batch run to, for comparing models over time. diff --git a/examples/jevdk/Package.swift b/examples/jevdk/Package.swift index a975b88..8dccd0a 100644 --- a/examples/jevdk/Package.swift +++ b/examples/jevdk/Package.swift @@ -16,7 +16,7 @@ import PackageDescription // so it declares Core itself. Same SDKRelease/knownSDKReleases/failManifest version gate as every // other example here. // -// Requires SDK 2.0.0-GA or later (the decision API is new in 2.0), macOS 27 + Xcode 27. No Metal +// Requires SDK 2.3.0-GA or later (the OpenAI backend needs 2.3), macOS 27 + Xcode 27. No Metal // Toolchain needed — the prebuilt Inference xcframework bundles the compiled default.metallib. struct SDKRelease { @@ -31,26 +31,20 @@ struct SDKRelease { // The SDK release this example builds against with no setup. Build against another published // version: set LOCALLM_SDK_VERSION in your shell (works for `swift build` / CI, NOT inside Xcode), // or edit `defaultSDKVersion` here. For a release not listed, add its entry (URL + the `.sha256` -// next to each zip on the GitHub release) or just replace the strings in place. Only 2.0 -// releases: this app does not build against 1.0. -let defaultSDKVersion = "2.2.0-GA" +// next to each zip on the GitHub release) or just replace the strings in place. Only 2.3 and +// later: the OpenAI backend doesn't build against 2.2 or earlier. +let defaultSDKVersion = "2.3.0-GA" let knownSDKReleases: [String: SDKRelease] = [ - "2.1.0-GA": SDKRelease( - coreURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.1.0-GA/LocalLMLabSDKCore-2.1.0-GA.xcframework.zip", - coreChecksum: "8d541e06f81d1586c959d6159a259c759853c88b2c68385ab28de493af6c5776", - remoteURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.1.0-GA/LocalLMLabSDKRemote-2.1.0-GA.xcframework.zip", - remoteChecksum: "347257172a6da7104b7081602e9dbfc853ed5d038b5e30831aad8dfa2753ae92", - inferenceURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.1.0-GA/LocalLMLabSDKInference-2.1.0-GA.xcframework.zip", - inferenceChecksum: "77d3d1a1bae9df6de5955dfa945242c177cb129b4896c95befb81db6c923656f" - ), - "2.2.0-GA": SDKRelease( - coreURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.2.0-GA/LocalLMLabSDKCore-2.2.0-GA.xcframework.zip", - coreChecksum: "5efc40cae9f8a1bf53bfe03195c5365398bc9501b3e6b0feee3e5c67385537f4", - remoteURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.2.0-GA/LocalLMLabSDKRemote-2.2.0-GA.xcframework.zip", - remoteChecksum: "1c9af025c700bc0c41c80387c1d6a59ba2f2ab6203eb8ffd8548d61c6ac1fe38", - inferenceURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.2.0-GA/LocalLMLabSDKInference-2.2.0-GA.xcframework.zip", - inferenceChecksum: "afd427fb741a820f55bc0a384850ae6404608f5ad73d185a583a89e8f0d61a61" + // 2.3.0-GA: OpenAI's Decisions API (JevProviderConfig.openAI) and Decision.refusals, which + // JevDK's OpenAI backend needs. + "2.3.0-GA": SDKRelease( + coreURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.3.0-GA/LocalLMLabSDKCore-2.3.0-GA.xcframework.zip", + coreChecksum: "7821197d0cf5b4c92e36af5c537295e594afbb55d60d2f98b0773a3625403575", + remoteURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.3.0-GA/LocalLMLabSDKRemote-2.3.0-GA.xcframework.zip", + remoteChecksum: "dae65d45bea196f6cd2538328fad1a0e33eceae5fac5ee965183706ac942daaf", + inferenceURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.3.0-GA/LocalLMLabSDKInference-2.3.0-GA.xcframework.zip", + inferenceChecksum: "63dc8bcba5e91e1324809ca412a23ebb0b9e04386361436eacc0be9fe6d4a8e0" ), ] diff --git a/examples/jevdk/README.md b/examples/jevdk/README.md index 5c00a43..f76559f 100644 --- a/examples/jevdk/README.md +++ b/examples/jevdk/README.md @@ -9,7 +9,7 @@ swift build -c release ``` Needs an Apple-silicon Mac, macOS 27 and Xcode 27 (Swift 6.4). `Package.swift` builds against SDK -`2.2.0-GA` with no further setup (the decision API is new in 2.0): `LocalLMLabSDKCore`, +`2.3.0-GA` with no further setup (the OpenAI backend needs 2.3): `LocalLMLabSDKCore`, `LocalLMLabSDKRemote` and `LocalLMLabSDKInference` (the MLX runtime) come from that GitHub Release. No Metal Toolchain needed; the prebuilt Inference xcframework bundles the compiled shaders. To build against another release, set `LOCALLM_SDK_VERSION` (see [the README one folder up](../README.md)). @@ -108,7 +108,7 @@ the runs line up in one spreadsheet. The first click asks for a file; later clic | Columns | | |---|---| | Run | `run_id` (date and time the batch started, plus a short suffix), `saved_at`, `app`, `sdk_version`, `machine`, `question_set` | -| Model | `backend` (local / featherlessDemo / featherless / openRouter), `model`, `model_revision`, `model_size_gb`, `moe`, `wrapper_label`, `wrapper_system`, `calibrated` | +| Model | `backend` (local / featherlessDemo / featherless / openRouter / openAI), `model`, `model_revision`, `model_size_gb`, `moe`, `wrapper_label`, `wrapper_system`, `calibrated` | | Item | `input`, `question`, `question_kind`, `question_text` | | Result | `expected` (your marked answer), `answer`, `correct` (yes / no), `confidence`, `probabilities`, `label_mass` | | Cost | `question_ms`, `decision_ms`, `input_tokens`, `cost_usd` (hosted, per decision) | @@ -184,7 +184,8 @@ From the SDK's own OpenJev evaluation (a labelled test set scored on several loc ## Choosing a decider -- **Local** (toolbar picker; **Models** to change): MLX models the SDK has downloaded and +- **Local** (chosen in **Models**; the toolbar lists it with the hosted backends that are on): + MLX models the SDK has downloaded and verified. **Models** has three tabs: **Installed** (use or remove), **Recommended** (small dense instruct models, which ones were tested as deciders) and **Search Hugging Face** (MLX models, most downloaded first). Select one, **Check** (the SDK confirms the repo is reachable, @@ -193,8 +194,10 @@ From the SDK's own OpenJev evaluation (a labelled test set scored on several loc **Verify** instead. `Qwen3-4B-4bit` is the current pick: about 150 ms for four questions, 2.6 GB. Mixture-of-experts models are flagged; use them for chat, not for deciding. - **Hosted** (Backends): the **Featherless demo** is free and needs no key (rate-limited); - **Featherless** and **OpenRouter · TypeSafe** take a key, stored in the Keychain. When any is - on, the status bar says where inputs are sent. + **Featherless**, **OpenRouter · TypeSafe** and **OpenAI** (GPT-6 Luna through OpenAI's + Decisions API) take a key, stored in the Keychain. When any is on, the status bar says where + inputs are sent. OpenAI can decline a question: the cell then says **refused**, which counts as + a miss against a marked answer. ## For SDK developers @@ -218,7 +221,8 @@ Headless checks: ``` `--check` needs a model the SDK has downloaded (or verified). `--check-remote` takes -`featherlessDemo`, `featherless` or `openRouter`, using the key saved in **Backends** (the -Keychain) or, for CI, the `OPENROUTER_API_KEY` / `FEATHERLESS_API_KEY` environment variable. Keys +`featherlessDemo`, `featherless`, `openRouter` or `openAI`, using the key saved in **Backends** +(the Keychain) or, for CI, the `OPENROUTER_API_KEY` / `FEATHERLESS_API_KEY` / `OPENAI_API_KEY` +environment variable. Keys are never printed, and never read from a file. The window hasn't been driven by a test. diff --git a/examples/jevdk/Sources/JevDK/AppModel.swift b/examples/jevdk/Sources/JevDK/AppModel.swift index b19a334..2417d44 100644 --- a/examples/jevdk/Sources/JevDK/AppModel.swift +++ b/examples/jevdk/Sources/JevDK/AppModel.swift @@ -96,6 +96,8 @@ final class AppModel { var downloadRepo = "" var preflight: PreflightResult? var downloadProgress: Double? + /// The "model is ready, try it" notice after a download, cleared by the first run. + private var readyNotice: String? /// The SDK front door hosted runs go through. let lab = LocalLMLab() @@ -184,7 +186,7 @@ final class AppModel { lab.models.replace(decision: JevDecisionProvider(b.config(key: b.needsKey ? apiKey(b) : nil))) let route = RouteName("hosted.\(b.rawValue)") lab.models.route(decision: route, to: id) - let body = (try? JevDecisionProvider.requestBody(model: model, request: request)) + let body = (try? JevDecisionProvider.requestBody(model: model, request: request, wire: b.config(key: nil).wire)) .flatMap { try? JSONSerialization.jsonObject(with: $0) } .flatMap { try? JSONSerialization.data(withJSONObject: $0, options: [.prettyPrinted]) } .map { String(decoding: $0, as: UTF8.self) } ?? "" @@ -272,6 +274,7 @@ final class AppModel { guard !isRunning else { return } if let problem = validationProblem() { errorMessage = problem; return } errorMessage = nil + if notice != nil && notice == readyNotice { notice = nil } // it said what to do; done isRunning = true defer { isRunning = false } results = await runAll(input) @@ -559,6 +562,10 @@ final class AppModel { // MARK: Models /// Text models tried as deciders or worth trying; all MLX 4-bit builds on Hugging Face. + /// The one GettingStarted offers to download: the tested default. + static let starterRepo = "mlx-community/Qwen3-4B-4bit" + static let starterSize = "2.3 GB" + static let recommended: [(repo: String, note: String)] = [ ("mlx-community/Qwen3-4B-4bit", "2.3 GB · tested as a decider: the current pick"), ("mlx-community/Qwen3-1.7B-4bit", "1.0 GB · tested: fast, but weaker on yes/no and answer checks"), @@ -628,6 +635,10 @@ final class AppModel { selectedModelID = repo downloadRepo = "" preflight = nil + notice = samples.isEmpty + ? "\(repo) is ready. Click Ask to try it." + : "\(repo) is ready. Click a sample message above the input to try it." + readyNotice = notice } catch { errorMessage = "Download failed: \(error.localizedDescription)" } @@ -710,7 +721,9 @@ final class AppModel { private func restoreWorkspace() { guard let data = UserDefaults.standard.data(forKey: Self.workspaceKey), let w = try? JSONDecoder().decode(Workspace.self, from: data) else { - samples = Presets.all.first { $0.set.name == set.name }?.samples ?? [] + // First launch: Customer support, whose sample messages make a first answer one click + // away once a model is downloaded (GettingStarted). + loadPreset(Presets.all.first { $0.set.name == Presets.support.name } ?? Presets.all[0]) return } restoring = true diff --git a/examples/jevdk/Sources/JevDK/HostedBackends.swift b/examples/jevdk/Sources/JevDK/HostedBackends.swift index 84631d1..65e1d84 100644 --- a/examples/jevdk/Sources/JevDK/HostedBackends.swift +++ b/examples/jevdk/Sources/JevDK/HostedBackends.swift @@ -1,45 +1,87 @@ +import AppKit import Foundation import LocalLMLabSDKCore import LocalLMLabSDKRemote -/// The hosted Jev backends JevDK offers, each an SDK `JevDecisionProvider` registered in the -/// lab under its own decision scheme. +/// The hosted backends JevDK offers, each an SDK `JevDecisionProvider` registered in the lab under +/// its own decision scheme: hosted Jev (Featherless, TypeSafe on OpenRouter) and OpenAI's Decisions +/// API (SDK 2.3, a different wire format behind the same provider). The Mac App Store build has no free Featherless demo: every +/// hosted backend there runs on the user's own account and key, and the demo isn't in its binary +/// at all (guideline 2.5.19: no dormant features). enum HostedBackend: String, CaseIterable, Identifiable, Sendable { - case featherlessDemo, featherless, openRouter + #if !MAS_BUILD + case featherlessDemo + #endif + case featherless, openRouter, openAI var id: String { rawValue } var displayName: String { switch self { + #if !MAS_BUILD case .featherlessDemo: "Featherless demo" + #endif case .featherless: "Featherless" case .openRouter: "OpenRouter · TypeSafe" + case .openAI: "OpenAI" } } var blurb: String { switch self { + #if !MAS_BUILD case .featherlessDemo: "Free, no key · 2k-token context, a few requests per second · token-scored like Local" + #endif case .featherless: "featherless.ai key · token-scored like Local" case .openRouter: "OpenRouter key · TypeSafe's decision-trained Jev" + case .openAI: "OpenAI key · GPT-6 Luna through OpenAI's Decisions API · may decline a question" } } - var needsKey: Bool { self != .featherlessDemo } + /// Featherless's free demo service (not in the Mac App Store build). + var isDemo: Bool { + #if MAS_BUILD + false + #else + self == .featherlessDemo + #endif + } + + var needsKey: Bool { !isDemo } - /// Environment variable checked when no key is saved in the Keychain (CI, headless runs). + /// Who receives the text a run sends, for the consent question (AIDataConsent). + var dataRecipient: String { + switch self { + #if !MAS_BUILD + case .featherlessDemo: "Featherless's free demo service (featherless.ai)" + #endif + case .featherless: "Featherless (featherless.ai)" + case .openRouter: "OpenRouter (openrouter.ai), which passes it to TypeSafe's Jev model" + case .openAI: "OpenAI (openai.com)" + } + } + + #if !MAS_BUILD + /// Environment variable checked when no key is saved in the Keychain (CI, headless runs). Not + /// in the Mac App Store build, whose keys come only from Backends. var keyVariable: String? { switch self { case .featherlessDemo: nil case .featherless: "FEATHERLESS_API_KEY" case .openRouter: "OPENROUTER_API_KEY" + case .openAI: "OPENAI_API_KEY" } } + #endif var defaultModel: String { switch self { - case .featherlessDemo, .featherless: "featherless-ai/Qwen3.8-27B-classifier" + #if !MAS_BUILD + case .featherlessDemo: "featherless-ai/Qwen3.8-27B-classifier" + #endif + case .featherless: "featherless-ai/Qwen3.8-27B-classifier" case .openRouter: "typesafe/jev-1.13" + case .openAI: "gpt-6-luna" } } @@ -48,9 +90,12 @@ enum HostedBackend: String, CaseIterable, Identifiable, Sendable { func config(key: String?) -> JevProviderConfig { var c: JevProviderConfig switch self { + #if !MAS_BUILD case .featherlessDemo: c = .featherlessDemo() + #endif case .featherless: c = .featherless(key: key ?? "") case .openRouter: c = .openRouter(key: key ?? "") + case .openAI: c = .openAI(key: key ?? "") } c.scheme = rawValue.lowercased() return c @@ -59,11 +104,15 @@ enum HostedBackend: String, CaseIterable, Identifiable, Sendable { var suggestedModels: [String] { config(key: nil).models } /// The free demo is rate-limited; space batch inputs out when it's on. - var minInterval: Duration { self == .featherlessDemo ? .milliseconds(600) : .zero } + var minInterval: Duration { isDemo ? .milliseconds(600) : .zero } /// The demo publishes a model list; the others don't have a Jev-specific one. var modelsURL: URL? { - self == .featherlessDemo ? URL(string: "https://simple-jev-demo-api.featherless.ai/v1/models") : nil + #if MAS_BUILD + nil + #else + isDemo ? URL(string: "https://simple-jev-demo-api.featherless.ai/v1/models") : nil + #endif } func listModels() async -> [String] { @@ -82,15 +131,42 @@ enum HostedBackend: String, CaseIterable, Identifiable, Sendable { } } -/// The API key for a hosted backend: the one saved in the Keychain from **Backends**, else the -/// environment variable (for CI and headless runs). Never printed. There is no key file: a key -/// in a file next to the code is too easy to commit or share. +/// The API key for a hosted backend: the one saved in the Keychain from **Backends**, else (not in +/// the Mac App Store build) the environment variable, for CI and headless runs. Never printed. +/// There is no key file: a key in a file next to the code is too easy to commit or share. enum APIKeys { static func key(for backend: HostedBackend) -> String? { if let k = Keychain.get(account(backend)), !k.isEmpty { return k } + #if MAS_BUILD + return nil + #else guard let name = backend.keyVariable, let v = ProcessInfo.processInfo.environment[name], !v.isEmpty else { return nil } return v + #endif } static func account(_ backend: HostedBackend) -> String { "key.\(backend.rawValue)" } } + +/// Asks before a hosted backend is turned on, in the Mac App Store build (App Review guideline +/// 5.1.2(i): name the AI service and what it receives, and get permission first). Asked every time +/// one is turned on, so turning it off is how to withdraw. Other builds don't ask: the Backends +/// sheet already says what hosted backends receive. +@MainActor +enum AIDataConsent { + static func ask(_ backend: HostedBackend) -> Bool { + #if MAS_BUILD + let alert = NSAlert() + alert.messageText = "Send your text to \(backend.displayName)?" + alert.informativeText = "While \(backend.displayName) is on, every input you run and your questions " + + "(with their instructions and options) are sent to \(backend.dataRecipient) to be answered. " + + "Their terms and privacy policy apply. The Local backend keeps everything on this Mac.\n\n" + + "Turn the backend off in Backends to stop sending." + alert.addButton(withTitle: "Allow") + alert.addButton(withTitle: "Cancel") + return alert.runModal() == .alertFirstButtonReturn + #else + true + #endif + } +} diff --git a/examples/jevdk/Sources/JevDK/JevDKApp.swift b/examples/jevdk/Sources/JevDK/JevDKApp.swift index 676aa5d..76b1b07 100644 --- a/examples/jevdk/Sources/JevDK/JevDKApp.swift +++ b/examples/jevdk/Sources/JevDK/JevDKApp.swift @@ -10,6 +10,9 @@ struct JevDKApp: App { @State private var model = AppModel() init() { + // Command-line modes for development and the DMG release script. Not in the Mac App Store + // build (guideline 2.5.19: no undocumented features; its icon is rendered by a non-MAS build). + #if !MAS_BUILD // Headless checks, both through `lab.decide` like the window: // `JevDK --check ` runs a preset's batch on a local model the SDK // has downloaded and prints the answers, then exits. @@ -31,6 +34,7 @@ struct JevDKApp: App { if let i = args.firstIndex(of: "--write-iconset"), args.count > i + 1 { AppIcon.writeIconset(to: URL(fileURLWithPath: args[i + 1])) } + #endif // Launched with `swift run` there is no app bundle, so macOS starts this as a // background-style process that never becomes active. Claim regular-app status @@ -54,7 +58,20 @@ struct JevDKApp: App { // questions for your app (the SDK's DecisionQuestionSet), answers (CSV), and results (CSV). CommandGroup(replacing: .appInfo) { Button("About JevDK") { AboutPanel.show() } + #if MAS_BUILD + // The Mac App Store build is sandboxed and can't put a command on the PATH, so it + // doesn't carry jev-serve; this opens its download page instead. + Button("Get jev-serve…") { NSWorkspace.shared.open(JevServeInstaller.downloadPage) } + #else Button("Install jev-serve Command…") { JevServeInstaller.install() } + #endif + } + // Help: the guide, instead of macOS's "Help isn't available for JevDK". + CommandGroup(replacing: .help) { + Link("JevDK Guide", destination: AboutPanel.guidePage) + Link("Decision Models (Jev) in Your App", destination: AboutPanel.webPage) + Divider() + Link("Privacy Policy", destination: AboutPanel.privacyPage) } CommandGroup(replacing: .newItem) { Section("Workspace") { @@ -93,13 +110,20 @@ struct ContentView: View { @AppStorage("showQuestionsPanel") private var showEditor = true var body: some View { - HSplitView { - if showEditor { - EditorView(model: model) - .frame(minWidth: 380, idealWidth: 460) + // The status bar goes below the split view, not in a bottom safe-area inset: HSplitView's + // panes don't respect the inset, so it covered the bottom of the batch grid (its last row + // and horizontal scroller). + VStack(spacing: 0) { + HSplitView { + if showEditor { + EditorView(model: model) + .frame(minWidth: 380, idealWidth: 460) + } + ResultsView(model: model, openModels: { showDownload = true }, openBackends: { showBackends = true }) + .frame(minWidth: 480) } - ResultsView(model: model) - .frame(minWidth: 480) + Divider() + statusBar } .toolbar { ToolbarItemGroup(placement: .navigation) { @@ -120,15 +144,16 @@ struct ContentView: View { } } ToolbarItemGroup(placement: .primaryAction) { - Picker("Model", selection: $model.selectedModelID) { - if model.models.isEmpty { Text("No models found").tag(String?.none) } - ForEach(model.models) { m in - Text("\(m.displayName) · \(m.sizeText)\(m.isMoE ? " · MoE ⚠︎" : "")") - .tag(Optional(m.id)) - } + // What a run uses: the local model (chosen in Models) and the hosted backends that + // are on (Backends). One label, not a second model picker. + Button { + showBackends = true + } label: { + Text(activeBackends) + .lineLimit(1) + .frame(maxWidth: 300) } - .frame(width: 300) - .help(modelHelp) + .help(backendsHelp) Button { model.refreshModels() } label: { @@ -145,10 +170,9 @@ struct ContentView: View { } label: { Label("Backends", systemImage: "square.3.layers.3d") } - .help("Compare the local model with hosted Jev (Featherless, OpenRouter · TypeSafe)") + .help("Compare the local model with hosted deciders (Featherless, OpenRouter · TypeSafe, OpenAI)") } } - .safeAreaInset(edge: .bottom) { statusBar } .sheet(isPresented: $showDownload) { ModelsSheet(model: model) } .confirmationDialog("Replace your current questions with “\(pendingPreset?.set.name ?? "")”?", isPresented: Binding(get: { pendingPreset != nil }, set: { if !$0 { pendingPreset = nil } })) { @@ -170,6 +194,22 @@ struct ContentView: View { .sheet(isPresented: $showBackends) { BackendsSheet(model: model) } } + /// "Qwen3-4B-4bit + OpenRouter · TypeSafe + OpenAI", or "No backends on". + private var activeBackends: String { + var names: [String] = [] + if model.useLocal { + names.append(model.selectedModel.map { "\($0.displayName)\($0.isMoE ? " ⚠︎" : "")" } ?? "No local model") + } + names += model.enabledHosted.map(\.displayName) + return names.isEmpty ? "No backends on" : names.joined(separator: " + ") + } + + private var backendsHelp: String { + var lines = ["The deciders a run uses. Click to turn backends on or off; choose the local model in Models."] + if model.useLocal { lines.append(modelHelp) } + return lines.joined(separator: "\n\n") + } + private var modelHelp: String { guard let m = model.selectedModel else { return "No SDK-verified models yet: open Models to choose one." } var s = "\(m.repoID)\(m.shortRevision.map { " @ \($0)" } ?? "") · \(m.sizeText) · verified by the SDK" @@ -208,6 +248,7 @@ struct ContentView: View { } } +#if !MAS_BUILD /// `--check` / `--check-remote`: a preset's batch through `lab.decide`, printed. @MainActor enum HeadlessCheck { @@ -273,7 +314,8 @@ enum HeadlessCheck { backend: info.model) lines += ResultsCSV.rows(run: runInfo, backend: info, result: result, labels: [:], inputTokens: d.usage?.inputTokens, costUSD: d.usage?.cost) - let cells = rows.map { "\($0.question.name)=\($0.answer) \(Int(($0.confidence * 100).rounded()))%" } + let cells = rows.map { $0.refused ? "\($0.question.name)=refused" + : "\($0.question.name)=\($0.answer) \(Int(($0.confidence * 100).rounded()))%" } let ms = Double(d.latency.components.seconds) * 1000 + Double(d.latency.components.attoseconds) / 1e15 print(String(format: "%5.0f ms ", ms) + line.prefix(60) + "\n " + cells.joined(separator: " ") + (d.usage?.summary.map { "\n [\($0)]" } ?? "")) @@ -297,6 +339,7 @@ enum HeadlessCheck { exit(0) } } +#endif struct BackendsSheet: View { @Bindable var model: AppModel @@ -323,7 +366,8 @@ struct BackendsSheet: View { ForEach(HostedBackend.allCases) { b in GroupBox { VStack(alignment: .leading, spacing: 8) { - Toggle(isOn: Binding(get: { model.hostedEnabled[b] ?? false }, set: { model.hostedEnabled[b] = $0 })) { + Toggle(isOn: Binding(get: { model.hostedEnabled[b] ?? false }, + set: { on in if !on || AIDataConsent.ask(b) { model.hostedEnabled[b] = on } })) { VStack(alignment: .leading) { Text(b.displayName).fontWeight(.medium) Text(b.blurb).font(.jCaption).foregroundStyle(.secondary) @@ -383,6 +427,7 @@ struct BackendsSheet: View { /// administrator password; if that doesn't work, it shows the command to run instead. enum JevServeInstaller { static let target = "/usr/local/bin/jev-serve" + static let downloadPage = URL(string: "https://thisbrain.ai/locallm/jev-serve.html")! static func install() { guard let tool = Bundle.main.url(forAuxiliaryExecutable: "jev-serve")?.path, @@ -442,6 +487,9 @@ enum JevServeInstaller { /// JevDK → About: the icon, the version, and where to read about decision models. enum AboutPanel { static let webPage = URL(string: "https://thisbrain.ai/locallm/jev.html")! + static let guidePage = URL(string: "https://thisbrain.ai/locallm/jdk-guide.html")! + /// Guideline 5.1.1(i): a privacy policy link inside the app (Help and About). + static let privacyPage = URL(string: "https://thisbrain.ai/locallm/privacy.html#jevdk")! /// The app's version when it runs as a built app (Xcode, or the DMG's), else "development build" /// (`swift run` has no bundle to carry one). @@ -462,6 +510,10 @@ enum AboutPanel { credits.append(NSAttributedString(string: "Decision models (Jev) in your app", attributes: [ .font: body, .link: webPage, .paragraphStyle: centered, ])) + credits.append(NSAttributedString(string: "\n", attributes: [.font: body, .paragraphStyle: centered])) + credits.append(NSAttributedString(string: "Privacy Policy", attributes: [ + .font: body, .link: privacyPage, .paragraphStyle: centered, + ])) NSApplication.shared.orderFrontStandardAboutPanel(options: [ .applicationIcon: AppIcon.image(), .applicationName: "JevDK", @@ -479,6 +531,7 @@ enum AboutPanel { enum AppIcon { static let blue = NSColor(srgbRed: 0x2f / 255, green: 0x6f / 255, blue: 0xed / 255, alpha: 1) + #if !MAS_BUILD /// The `.iconset` folder `iconutil -c icns` turns into AppIcon.icns. Exits. static func writeIconset(to dir: URL) -> Never { do { @@ -504,6 +557,7 @@ enum AppIcon { exit(1) } } + #endif static func image(size: CGFloat = 512) -> NSImage { NSImage(size: NSSize(width: size, height: size), flipped: false) { rect in diff --git a/examples/jevdk/Sources/JevDK/Keychain.swift b/examples/jevdk/Sources/JevDK/Keychain.swift index 7997c16..65e1c33 100644 --- a/examples/jevdk/Sources/JevDK/Keychain.swift +++ b/examples/jevdk/Sources/JevDK/Keychain.swift @@ -2,22 +2,69 @@ import Foundation import Security /// API keys for remote Jev providers, in the login Keychain. Never written to disk or logs. +/// +/// All keys live in **one** Keychain item (a small JSON object keyed by account), read once per +/// launch and cached, so macOS asks for the Keychain password at most once per launch rather than +/// once per hosted backend. (An ad-hoc-signed build counts as a new app after every rebuild, so +/// "Always Allow" doesn't carry over; one item keeps that to one prompt.) Keys saved by earlier +/// versions, one item per backend, are moved into it the first time each is read. enum Keychain { + #if MAS_BUILD + /// The Mac App Store build's own items, so with both builds installed it never finds (and makes + /// macOS ask about) the DMG build's keys. + private static let service = "ai.thisbrain.jevdk" + #else private static let service = "JevDK" + #endif + /// The one item holding every key. + private static let bundleAccount = "keys" + + private static let lock = NSLock() + private static var cache: [String: String]? + /// Accounts already checked for an old per-backend item this launch. + private static var migrated: Set = [] static func get(_ account: String) -> String? { - read(account, service: service) + lock.lock(); defer { lock.unlock() } + var keys = loadLocked() + if let v = keys[account] { return v } + guard migrated.insert(account).inserted, let old = read(account), !old.isEmpty else { return nil } + keys[account] = old + if writeLocked(keys) { delete(account) } + return old } static func set(_ value: String?, for account: String) { - delete(account, service: service) - guard let value, !value.isEmpty else { return } - let add: [String: Any] = [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, - kSecAttrAccount as String: account, kSecValueData as String: Data(value.utf8)] - SecItemAdd(add as CFDictionary, nil) + lock.lock(); defer { lock.unlock() } + var keys = loadLocked() + keys[account] = (value?.isEmpty ?? true) ? nil : value + migrated.insert(account) + _ = writeLocked(keys) + delete(account) // an old per-backend item, if one is left + } + + private static func loadLocked() -> [String: String] { + if let cache { return cache } + let keys = read(bundleAccount) + .flatMap { try? JSONDecoder().decode([String: String].self, from: Data($0.utf8)) } ?? [:] + cache = keys + return keys + } + + /// Replaces the item. Returns false (and keeps the cache unchanged) if the Keychain refused it. + private static func writeLocked(_ keys: [String: String]) -> Bool { + delete(bundleAccount) + if !keys.isEmpty { + guard let data = try? JSONEncoder().encode(keys) else { return false } + let add: [String: Any] = [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, + kSecAttrAccount as String: bundleAccount, kSecValueData as String: data] + guard SecItemAdd(add as CFDictionary, nil) == errSecSuccess else { return false } + } + cache = keys + return true } - private static func read(_ account: String, service: String) -> String? { + private static func read(_ account: String) -> String? { let q: [String: Any] = [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecReturnData as String: true] var out: AnyObject? @@ -25,7 +72,7 @@ enum Keychain { return String(data: data, encoding: .utf8) } - private static func delete(_ account: String, service: String) { + private static func delete(_ account: String) { let q: [String: Any] = [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account] SecItemDelete(q as CFDictionary) diff --git a/examples/jevdk/Sources/JevDK/ResultsView.swift b/examples/jevdk/Sources/JevDK/ResultsView.swift index ee9cf22..b43850c 100644 --- a/examples/jevdk/Sources/JevDK/ResultsView.swift +++ b/examples/jevdk/Sources/JevDK/ResultsView.swift @@ -1,3 +1,4 @@ +import AppKit import OpenJevKit import SwiftUI @@ -5,6 +6,8 @@ import SwiftUI /// (local, Featherless, OpenRouter · TypeSafe) answers the same questions, shown together. struct ResultsView: View { @Bindable var model: AppModel + var openModels: () -> Void = {} + var openBackends: () -> Void = {} @State private var mode = Mode.single @State private var showCalibration = false @@ -41,6 +44,11 @@ struct ResultsView: View { .padding(.top, 8) } + if model.useLocal && model.models.isEmpty { + GettingStarted(model: model, openModels: openModels, openBackends: openBackends) + .padding([.horizontal, .top]) + } + switch mode { case .single: single case .batch: batch @@ -260,12 +268,22 @@ struct BackendAnswer: View { Text(label).font(.jCaption.weight(.semibold)) Text(fidelityText).font(.jCaption2).foregroundStyle(.secondary) Spacer() - Text(result.answer).font(.jHeadline) - Text(percent(result.confidence)) - .font(.jHeadline.monospacedDigit()) - .foregroundStyle(confidenceColor(result.confidence)) + if result.refused { + Text("Refused").font(.jHeadline).foregroundStyle(.orange) + } else { + Text(result.answer).font(.jHeadline) + Text(percent(result.confidence)) + .font(.jHeadline.monospacedDigit()) + .foregroundStyle(confidenceColor(result.confidence)) + } + } + if result.refused { + Label("The backend declined to answer this question, so there are no probabilities. In your app it's in Decision.refusals (d.isRefused(\"\(result.question.name)\")) and has no answer.", + systemImage: "hand.raised") + .font(.jCaption) + .foregroundStyle(.secondary) } - ForEach(result.keys.indices, id: \.self) { i in + ForEach(result.refused ? [] : Array(result.keys.indices), id: \.self) { i in HStack(spacing: 8) { Text(result.labels[i]) .font(.system(size: 12, design: .monospaced).weight(.bold)) @@ -379,6 +397,7 @@ struct BatchGrid: View { } } .padding(.vertical, 4) + .background(AlwaysVisibleScrollers()) } .scrollIndicators(.visible) } else if let err = rows.first?.runs.first?.error { @@ -397,13 +416,22 @@ struct BatchGrid: View { if let results = run.result?.results, results.indices.contains(qi) { let r = results[qi] let verdict = mark.map { $0 == r.answer ? "✓ " : "✗ " } ?? "" - Text("\(tag.map { $0 + " " } ?? "")\(verdict)\(r.answer) \(percent(r.confidence))") - .font(.jCaption.monospacedDigit()) - .padding(.horizontal, 5) - .padding(.vertical, 1) - .background(confidenceColor(r.confidence).opacity(0.18), in: RoundedRectangle(cornerRadius: 4)) - .help("\(run.label)\(r.fidelity == .tokenScored(calibrated: true) ? " (calibrated)" : "")\n" - + zip(r.keys, r.probabilities).map { "\($0): \(percent($1))" }.joined(separator: "\n")) + if r.refused { + Text("\(tag.map { $0 + " " } ?? "")\(verdict)refused") + .font(.jCaption) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Color.orange.opacity(0.18), in: RoundedRectangle(cornerRadius: 4)) + .help("\(run.label) declined to answer this question.") + } else { + Text("\(tag.map { $0 + " " } ?? "")\(verdict)\(r.answer) \(percent(r.confidence))") + .font(.jCaption.monospacedDigit()) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(confidenceColor(r.confidence).opacity(0.18), in: RoundedRectangle(cornerRadius: 4)) + .help("\(run.label)\(r.fidelity == .tokenScored(calibrated: true) ? " (calibrated)" : "")\n" + + zip(r.keys, r.probabilities).map { "\($0): \(percent($1))" }.joined(separator: "\n")) + } } else { Text("\(tag.map { $0 + " " } ?? "")error").font(.jCaption).foregroundStyle(.orange).help(run.error ?? "") } @@ -473,6 +501,91 @@ func confidenceColor(_ p: Double) -> Color { p >= 0.9 ? .green : p >= 0.6 ? .yellow : .orange } +/// Gives the enclosing scroll view classic scrollbars that stay on screen whenever the content +/// overflows. With macOS's default overlay scrollbars (Show scroll bars: Automatically), the batch +/// grid's horizontal scroller only appeared mid-scroll, so nothing showed there were more question +/// columns to the right, and a mouse wheel can't scroll sideways. +struct AlwaysVisibleScrollers: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { Probe() } + func updateNSView(_ view: NSView, context: Context) { (view as? Probe)?.apply() } + + final class Probe: NSView { + private var observer: NSObjectProtocol? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + apply() + // macOS resets the style when the scroll bar preference or input device changes. + if observer == nil { + observer = NotificationCenter.default.addObserver( + forName: NSScroller.preferredScrollerStyleDidChangeNotification, object: nil, queue: .main + ) { [weak self] _ in self?.apply() } + } + } + + deinit { observer.map(NotificationCenter.default.removeObserver) } + + func apply() { + DispatchQueue.main.async { [weak self] in + guard let scroll = self?.enclosingScrollView else { return } + scroll.scrollerStyle = .legacy + scroll.hasHorizontalScroller = true + scroll.hasVerticalScroller = true + scroll.autohidesScrollers = true // only when the content doesn't fit + } + } + } +} + +/// Shown while no local model is installed (first launch): one click to the tested default model, +/// so the examples can answer. +struct GettingStarted: View { + @Bindable var model: AppModel + var openModels: () -> Void + var openBackends: () -> Void + + private var starterName: String { String(AppModel.starterRepo.split(separator: "/").last ?? "") } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Label("Get started: download a model", systemImage: "arrow.down.circle") + .font(.jCallout.weight(.semibold)) + Text("JevDK asks your questions to a model that runs on this Mac. The examples were tested with \(starterName), a \(AppModel.starterSize) download.") + .font(.jCallout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + if let p = model.downloadProgress { + ProgressView(value: p) { + Text("Downloading \(starterName)… \(percent(p))").font(.jCaption) + } + } else { + Button { + Task { await model.download(AppModel.starterRepo) } + } label: { + Label("Download \(starterName)", systemImage: "arrow.down") + } + .buttonStyle(.borderedProminent) + HStack(spacing: 4) { + Text("Or").foregroundStyle(.secondary) + Button("choose another model", action: openModels).buttonStyle(.link) + Text("or").foregroundStyle(.secondary) + Button("use a hosted model", action: openBackends).buttonStyle(.link) + } + .font(.jCaption) + if !model.unverifiedRepos.isEmpty { + Text("Models you already downloaded elsewhere are listed under Models in the toolbar, to verify.") + .font(.jCaption) + .foregroundStyle(.secondary) + } + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.tint.opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(.tint.opacity(0.3))) + } +} + /// Shown before the first Ask: what a decider does, in three lines. struct HowItWorks: View { var body: some View { diff --git a/examples/jevdk/Sources/OpenJevKit/EditorModel.swift b/examples/jevdk/Sources/OpenJevKit/EditorModel.swift index 37bd17d..f0d6cca 100644 --- a/examples/jevdk/Sources/OpenJevKit/EditorModel.swift +++ b/examples/jevdk/Sources/OpenJevKit/EditorModel.swift @@ -197,9 +197,14 @@ public struct QuestionResult: Identifiable, Sendable { public let prompt: String public let milliseconds: Double public let fidelity: DecisionFidelity + /// The backend declined to answer (OpenAI's Decisions API can). Probabilities are all 0, and + /// `answer` is "refused", which never matches a marked answer. + public var refused: Bool = false - public var topIndex: Int { probabilities.indices.max { probabilities[$0] < probabilities[$1] } ?? 0 } - public var answer: String { keys[topIndex] } + /// The most likely option, or -1 for a refusal. + public var topIndex: Int { refused ? -1 : probabilities.indices.max { probabilities[$0] < probabilities[$1] } ?? 0 } + public var answer: String { refused ? Self.refusedAnswer : keys[topIndex] } + public static let refusedAnswer = "refused" /// This answer with `probabilities` shown instead (a calibration applied to the raw ones). public func showing(_ probabilities: [Double], calibrated: Bool) -> QuestionResult { @@ -208,7 +213,7 @@ public struct QuestionResult: Identifiable, Sendable { probabilities: probabilities, rawProbabilities: rawProbabilities, confidence: probabilities.isEmpty ? 0 : probabilities[top], labelMass: labelMass, prompt: prompt, milliseconds: milliseconds, - fidelity: .tokenScored(calibrated: calibrated)) + fidelity: .tokenScored(calibrated: calibrated), refused: refused) } /// Build display rows from an SDK decision. `diagnostics` (local) supplies labels, prompts and @@ -220,6 +225,7 @@ public struct QuestionResult: Identifiable, Sendable { return questions.compactMap { q in let diag = diagnostics[q.name] let keys: [String], descs: [String], probs: [Double], conf: Double + var refused = false switch d.answers[q.name] { case .noul(let p)?: keys = ["Yes", "No"]; descs = ["", ""]; probs = [p, 1 - p]; conf = max(p, 1 - p) @@ -230,6 +236,16 @@ public struct QuestionResult: Identifiable, Sendable { probs = keys.map { c.probabilities[$0] ?? 0 }; conf = c.confidence case .score(let s)?: keys = s.legend.indices.map(String.init); descs = s.legend; probs = s.probabilities; conf = s.confidence + case nil where d.isRefused(q.name): + // A row of its own, so batch columns stay aligned with the questions. + refused = true + switch q.sdkQuestion.kind { + case .noul: keys = ["Yes", "No"]; descs = ["", ""] + case .choice(let criteria): keys = criteria.map(\.key); descs = criteria.map { $0.description ?? "" } + case .score(let levels): keys = levels.indices.map(String.init); descs = levels + @unknown default: return nil + } + probs = keys.map { _ in 0 }; conf = 0 default: return nil } @@ -239,7 +255,7 @@ public struct QuestionResult: Identifiable, Sendable { return QuestionResult( question: q, keys: keys, labels: diag?.labels ?? positional, descriptions: descs, probabilities: probs, rawProbabilities: probs, confidence: conf, labelMass: diag?.labelMass ?? 1, prompt: diag?.prompt ?? prompt, - milliseconds: diag?.milliseconds ?? perQuestionMS, fidelity: d.fidelity) + milliseconds: diag?.milliseconds ?? perQuestionMS, fidelity: d.fidelity, refused: refused) } } } diff --git a/examples/mcp-chat/Package.swift b/examples/mcp-chat/Package.swift index 146b748..5291b56 100644 --- a/examples/mcp-chat/Package.swift +++ b/examples/mcp-chat/Package.swift @@ -31,17 +31,17 @@ struct SDKRelease { // The SDK release this example builds against with no setup. Build against another published // version: set LOCALLM_SDK_VERSION in your shell (works for `swift build` / CI, NOT inside Xcode), // or edit `defaultSDKVersion` here. -let defaultSDKVersion = "2.2.0-GA" +let defaultSDKVersion = "2.3.0-GA" let knownSDKReleases: [String: SDKRelease] = [ - "2.1.0-GA": SDKRelease( - inferenceURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.1.0-GA/LocalLMLabSDKInference-2.1.0-GA.xcframework.zip", - inferenceChecksum: "77d3d1a1bae9df6de5955dfa945242c177cb129b4896c95befb81db6c923656f" - ), "2.2.0-GA": SDKRelease( inferenceURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.2.0-GA/LocalLMLabSDKInference-2.2.0-GA.xcframework.zip", inferenceChecksum: "afd427fb741a820f55bc0a384850ae6404608f5ad73d185a583a89e8f0d61a61" ), + "2.3.0-GA": SDKRelease( + inferenceURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.3.0-GA/LocalLMLabSDKInference-2.3.0-GA.xcframework.zip", + inferenceChecksum: "63dc8bcba5e91e1324809ca412a23ebb0b9e04386361436eacc0be9fe6d4a8e0" + ), ] func failManifest(_ message: String) -> Never { diff --git a/examples/mcp-chat/README.md b/examples/mcp-chat/README.md index 63c0ff7..2f9a399 100644 --- a/examples/mcp-chat/README.md +++ b/examples/mcp-chat/README.md @@ -7,7 +7,7 @@ shows Todoist's task list under the call. Qwen3 8B through MLX by default; Apple's on-device model as an alternative (Settings). -`Package.swift` builds against SDK `2.2.0-GA` with no further setup (MCP Apps support is new in +`Package.swift` builds against SDK `2.3.0-GA` with no further setup (MCP Apps support is new in 2.0): `LocalLMLabSDKCore` and `LocalLMLabSDKInference` (the MLX runtime) come from that GitHub Release, `Components` (`../../Components`) and `MCPAppsHost` (`../../MCPAppsHost`) from this repo as source. Needs macOS 27 and Xcode 27 (Swift 6.4). No Metal Toolchain needed — the prebuilt Inference @@ -34,7 +34,7 @@ open "dist/MCP Chat.app" ``` The first launch offers to download Qwen3 8B (about 4.6 GB, pinned to a reviewed revision) into the -app's sandbox container. +app's sandbox container — see [Where the model is stored](#where-the-model-is-stored). For development, a build without App Sandbox can use a model you already have instead: @@ -43,6 +43,48 @@ CONFIG=debug SANDBOX=0 packaging/build-and-sign.sh open --env MCPCHAT_MODEL_CACHE=/path/to/huggingface/hub "dist/MCP Chat.app" ``` +## Where the model is stored + +The sandboxed build (Xcode Run, or `packaging/build-and-sign.sh` without `SANDBOX=0`) can't write +outside its container, so swift-huggingface puts the Hugging Face cache there instead of +`~/.cache/huggingface`: + +``` +~/Library/Containers/lab.locallm.mcpapps.chat/Data/Library/Caches/huggingface/hub/ + models--mlx-community--Qwen3-8B-4bit/ + snapshots// # config.json, *.safetensors, tokenizer… + blobs/ # the actual bytes (snapshot files symlink here) +``` + +- The container is named after the bundle ID, `lab.locallm.mcpapps.chat`. The Xcode Run build and + `dist/MCP Chat.app` share it, so one download serves both. +- Nothing else shares it. A copy in `~/.cache/huggingface/hub` or in another example's container + isn't used (unless you point `MCPCHAT_MODEL_CACHE` at it from an unsandboxed build, above). +- To look at it in Finder: **Go ▸ Go to Folder…** (⇧⌘G) and paste the path. If macOS asks whether + Finder or Terminal may access another app's data, allow it. + +### Removing the model + +Quit MCP Chat first, then delete the model's folder: + +```bash +rm -rf ~/Library/Containers/lab.locallm.mcpapps.chat/Data/Library/Caches/huggingface/hub/models--mlx-community--Qwen3-8B-4bit +``` + +The next launch offers the download again, at the same pinned revision. If a download was +interrupted, the folder may hold only the small files (config, tokenizer); removing it makes the +next download start from scratch. + +To start completely fresh — the model, the saved conversation and the app's settings — quit the app +and remove the whole container. macOS creates a new one on the next launch: + +```bash +rm -rf ~/Library/Containers/lab.locallm.mcpapps.chat +``` + +If `rm` says *Operation not permitted*, macOS is protecting the app's data: allow Terminal when it +asks, or delete the folder in Finder instead. + ## Try it 1. Add a server with the server button in the toolbar: diff --git a/examples/mlx-control-room/Package.swift b/examples/mlx-control-room/Package.swift index b71c906..67e9252 100644 --- a/examples/mlx-control-room/Package.swift +++ b/examples/mlx-control-room/Package.swift @@ -25,21 +25,21 @@ struct SDKRelease { // its entry (URL + the `.sha256` next to the zip on the GitHub release) or just replace the strings in // place. Needs a release that includes the model pin / update / cleanup APIs (1.0.0-RC.1 as re-published // on 2026-09-18, or later). -let defaultSDKVersion = "2.2.0-GA" +let defaultSDKVersion = "2.3.0-GA" let knownSDKReleases: [String: SDKRelease] = [ - "2.1.0-GA": SDKRelease( - coreURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.1.0-GA/LocalLMLabSDKCore-2.1.0-GA.xcframework.zip", - coreChecksum: "8d541e06f81d1586c959d6159a259c759853c88b2c68385ab28de493af6c5776", - inferenceURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.1.0-GA/LocalLMLabSDKInference-2.1.0-GA.xcframework.zip", - inferenceChecksum: "77d3d1a1bae9df6de5955dfa945242c177cb129b4896c95befb81db6c923656f" - ), "2.2.0-GA": SDKRelease( coreURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.2.0-GA/LocalLMLabSDKCore-2.2.0-GA.xcframework.zip", coreChecksum: "5efc40cae9f8a1bf53bfe03195c5365398bc9501b3e6b0feee3e5c67385537f4", inferenceURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.2.0-GA/LocalLMLabSDKInference-2.2.0-GA.xcframework.zip", inferenceChecksum: "afd427fb741a820f55bc0a384850ae6404608f5ad73d185a583a89e8f0d61a61" ), + "2.3.0-GA": SDKRelease( + coreURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.3.0-GA/LocalLMLabSDKCore-2.3.0-GA.xcframework.zip", + coreChecksum: "7821197d0cf5b4c92e36af5c537295e594afbb55d60d2f98b0773a3625403575", + inferenceURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.3.0-GA/LocalLMLabSDKInference-2.3.0-GA.xcframework.zip", + inferenceChecksum: "63dc8bcba5e91e1324809ca412a23ebb0b9e04386361436eacc0be9fe6d4a8e0" + ), ] func failManifest(_ message: String) -> Never { diff --git a/examples/mlx-control-room/README.md b/examples/mlx-control-room/README.md index 4b020ac..cf14ec2 100644 --- a/examples/mlx-control-room/README.md +++ b/examples/mlx-control-room/README.md @@ -313,6 +313,42 @@ use. built-in models), *outside* the model cache on purpose: removing a model deletes its whole cache directory, and a pin kept there would be gone exactly when it's needed. +The sandboxed `.app` (Xcode Run, or `packaging/build-and-sign.sh`) can't write outside its container, so +swift-huggingface puts the Hugging Face cache there: + +``` +~/Library/Containers/lab.locallm.sdk.reference.mlxcontrolroom/Data/Library/Caches/huggingface/hub/ + models--mlx-community--Qwen2.5-0.5B-Instruct-4bit/ # one folder per model you've picked + snapshots// # one per version on disk + blobs/ # the actual bytes, shared between versions +``` + +The container is named after the bundle ID, `lab.locallm.sdk.reference.mlxcontrolroom`; the Xcode build and +the packaged `.app` share it. It isn't shared with `~/.cache/huggingface/hub` (where `swift run` keeps its +models) or with any other example's container. To look at it in Finder: **Go ▸ Go to Folder…** (⇧⌘G) and +paste the path. If macOS asks whether Finder or Terminal may access another app's data, allow it. + +### Removing models + +To remove one old version, use **Versions on disk ▸ Remove** in the app: it frees only the files no other +version uses and won't remove the version in use. To remove a model entirely, quit the app and delete its +folder: + +```bash +rm -rf ~/Library/Containers/lab.locallm.sdk.reference.mlxcontrolroom/Data/Library/Caches/huggingface/hub/models--mlx-community--Qwen2.5-0.5B-Instruct-4bit +``` + +Its pin stays, so picking it again downloads the same version. To start completely fresh — every model and +both pin files — quit the app and remove the whole container. macOS creates a new one on the next launch: + +```bash +rm -rf ~/Library/Containers/lab.locallm.sdk.reference.mlxcontrolroom +``` + +If `rm` says *Operation not permitted*, macOS is protecting the app's data: allow Terminal when it asks, or +delete the folder in Finder instead. For the `swift run` build, the same folders are under +`~/.cache/huggingface/hub`, and its pins under `~/Library/Application Support/MLXControlRoom/LocalLMLab/`. + ## Not production One window, one model at a time, no packaging beyond the sample `.app` — it's a harness for seeing what the diff --git a/examples/model-switch/Package.swift b/examples/model-switch/Package.swift index 7cc37da..a32a03d 100644 --- a/examples/model-switch/Package.swift +++ b/examples/model-switch/Package.swift @@ -28,17 +28,17 @@ struct SDKRelease { // `swift build` / CI, NOT inside Xcode), or edit `defaultSDKVersion` here. For a // release not listed, add its entry (URL + the `.sha256` next to the zip on the // GitHub release) or just replace the strings in place. -let defaultSDKVersion = "2.2.0-GA" +let defaultSDKVersion = "2.3.0-GA" let knownSDKReleases: [String: SDKRelease] = [ - "2.1.0-GA": SDKRelease( - remoteURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.1.0-GA/LocalLMLabSDKRemote-2.1.0-GA.xcframework.zip", - remoteChecksum: "347257172a6da7104b7081602e9dbfc853ed5d038b5e30831aad8dfa2753ae92" - ), "2.2.0-GA": SDKRelease( remoteURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.2.0-GA/LocalLMLabSDKRemote-2.2.0-GA.xcframework.zip", remoteChecksum: "1c9af025c700bc0c41c80387c1d6a59ba2f2ab6203eb8ffd8548d61c6ac1fe38" ), + "2.3.0-GA": SDKRelease( + remoteURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.3.0-GA/LocalLMLabSDKRemote-2.3.0-GA.xcframework.zip", + remoteChecksum: "dae65d45bea196f6cd2538328fad1a0e33eceae5fac5ee965183706ac942daaf" + ), ] func failManifest(_ message: String) -> Never { diff --git a/examples/os-matrix/Package.swift b/examples/os-matrix/Package.swift index 6c318bf..50bf354 100644 --- a/examples/os-matrix/Package.swift +++ b/examples/os-matrix/Package.swift @@ -22,21 +22,21 @@ struct SDKRelease { // `swift build` / CI, NOT inside Xcode), or edit `defaultSDKVersion` here. For a // release not listed, add its entry (URL + the `.sha256` next to the zip on the // GitHub release) or just replace the strings in place. -let defaultSDKVersion = "2.2.0-GA" +let defaultSDKVersion = "2.3.0-GA" let knownSDKReleases: [String: SDKRelease] = [ - "2.1.0-GA": SDKRelease( - coreURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.1.0-GA/LocalLMLabSDKCore-2.1.0-GA.xcframework.zip", - coreChecksum: "8d541e06f81d1586c959d6159a259c759853c88b2c68385ab28de493af6c5776", - inferenceURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.1.0-GA/LocalLMLabSDKInference-2.1.0-GA.xcframework.zip", - inferenceChecksum: "77d3d1a1bae9df6de5955dfa945242c177cb129b4896c95befb81db6c923656f" - ), "2.2.0-GA": SDKRelease( coreURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.2.0-GA/LocalLMLabSDKCore-2.2.0-GA.xcframework.zip", coreChecksum: "5efc40cae9f8a1bf53bfe03195c5365398bc9501b3e6b0feee3e5c67385537f4", inferenceURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.2.0-GA/LocalLMLabSDKInference-2.2.0-GA.xcframework.zip", inferenceChecksum: "afd427fb741a820f55bc0a384850ae6404608f5ad73d185a583a89e8f0d61a61" ), + "2.3.0-GA": SDKRelease( + coreURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.3.0-GA/LocalLMLabSDKCore-2.3.0-GA.xcframework.zip", + coreChecksum: "7821197d0cf5b4c92e36af5c537295e594afbb55d60d2f98b0773a3625403575", + inferenceURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.3.0-GA/LocalLMLabSDKInference-2.3.0-GA.xcframework.zip", + inferenceChecksum: "63dc8bcba5e91e1324809ca412a23ebb0b9e04386361436eacc0be9fe6d4a8e0" + ), ] func failManifest(_ message: String) -> Never { diff --git a/examples/plate-today-tools/Package.swift b/examples/plate-today-tools/Package.swift index 227c166..0ceaaef 100644 --- a/examples/plate-today-tools/Package.swift +++ b/examples/plate-today-tools/Package.swift @@ -23,17 +23,17 @@ struct SDKRelease { // `swift build` / CI, NOT inside Xcode), or edit `defaultSDKVersion` here. For a // release not listed, add its entry (URL + the `.sha256` next to the zip on the // GitHub release) or just replace the strings in place. -let defaultSDKVersion = "2.2.0-GA" +let defaultSDKVersion = "2.3.0-GA" let knownSDKReleases: [String: SDKRelease] = [ - "2.1.0-GA": SDKRelease( - url: "https://github.com/ancientcomputing/locallm/releases/download/v2.1.0-GA/LocalLMLabSDKCore-2.1.0-GA.xcframework.zip", - checksum: "8d541e06f81d1586c959d6159a259c759853c88b2c68385ab28de493af6c5776" - ), "2.2.0-GA": SDKRelease( url: "https://github.com/ancientcomputing/locallm/releases/download/v2.2.0-GA/LocalLMLabSDKCore-2.2.0-GA.xcframework.zip", checksum: "5efc40cae9f8a1bf53bfe03195c5365398bc9501b3e6b0feee3e5c67385537f4" ), + "2.3.0-GA": SDKRelease( + url: "https://github.com/ancientcomputing/locallm/releases/download/v2.3.0-GA/LocalLMLabSDKCore-2.3.0-GA.xcframework.zip", + checksum: "7821197d0cf5b4c92e36af5c537295e594afbb55d60d2f98b0773a3625403575" + ), ] func failManifest(_ message: String) -> Never { diff --git a/examples/plate-today/Package.swift b/examples/plate-today/Package.swift index 90a656b..0b27cce 100644 --- a/examples/plate-today/Package.swift +++ b/examples/plate-today/Package.swift @@ -20,17 +20,17 @@ struct SDKRelease { // edit `defaultSDKVersion` here. For a release not listed, add its entry — the URL follows the // pattern below and the checksum is the `.sha256` file next to the zip on that GitHub release — // or just replace the two strings in place. -let defaultSDKVersion = "2.2.0-GA" +let defaultSDKVersion = "2.3.0-GA" let knownSDKReleases: [String: SDKRelease] = [ - "2.1.0-GA": SDKRelease( - url: "https://github.com/ancientcomputing/locallm/releases/download/v2.1.0-GA/LocalLMLabSDKCore-2.1.0-GA.xcframework.zip", - checksum: "8d541e06f81d1586c959d6159a259c759853c88b2c68385ab28de493af6c5776" - ), "2.2.0-GA": SDKRelease( url: "https://github.com/ancientcomputing/locallm/releases/download/v2.2.0-GA/LocalLMLabSDKCore-2.2.0-GA.xcframework.zip", checksum: "5efc40cae9f8a1bf53bfe03195c5365398bc9501b3e6b0feee3e5c67385537f4" ), + "2.3.0-GA": SDKRelease( + url: "https://github.com/ancientcomputing/locallm/releases/download/v2.3.0-GA/LocalLMLabSDKCore-2.3.0-GA.xcframework.zip", + checksum: "7821197d0cf5b4c92e36af5c537295e594afbb55d60d2f98b0773a3625403575" + ), ] func failManifest(_ message: String) -> Never { diff --git a/examples/repo-qa-local/Package.swift b/examples/repo-qa-local/Package.swift index af80fc0..9468415 100644 --- a/examples/repo-qa-local/Package.swift +++ b/examples/repo-qa-local/Package.swift @@ -26,21 +26,21 @@ struct SDKRelease { // `swift build` / CI, NOT inside Xcode), or edit `defaultSDKVersion` here. For a // release not listed, add its entry (URL + the `.sha256` next to the zip on the // GitHub release) or just replace the strings in place. -let defaultSDKVersion = "2.2.0-GA" +let defaultSDKVersion = "2.3.0-GA" let knownSDKReleases: [String: SDKRelease] = [ - "2.1.0-GA": SDKRelease( - coreURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.1.0-GA/LocalLMLabSDKCore-2.1.0-GA.xcframework.zip", - coreChecksum: "8d541e06f81d1586c959d6159a259c759853c88b2c68385ab28de493af6c5776", - inferenceURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.1.0-GA/LocalLMLabSDKInference-2.1.0-GA.xcframework.zip", - inferenceChecksum: "77d3d1a1bae9df6de5955dfa945242c177cb129b4896c95befb81db6c923656f" - ), "2.2.0-GA": SDKRelease( coreURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.2.0-GA/LocalLMLabSDKCore-2.2.0-GA.xcframework.zip", coreChecksum: "5efc40cae9f8a1bf53bfe03195c5365398bc9501b3e6b0feee3e5c67385537f4", inferenceURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.2.0-GA/LocalLMLabSDKInference-2.2.0-GA.xcframework.zip", inferenceChecksum: "afd427fb741a820f55bc0a384850ae6404608f5ad73d185a583a89e8f0d61a61" ), + "2.3.0-GA": SDKRelease( + coreURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.3.0-GA/LocalLMLabSDKCore-2.3.0-GA.xcframework.zip", + coreChecksum: "7821197d0cf5b4c92e36af5c537295e594afbb55d60d2f98b0773a3625403575", + inferenceURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.3.0-GA/LocalLMLabSDKInference-2.3.0-GA.xcframework.zip", + inferenceChecksum: "63dc8bcba5e91e1324809ca412a23ebb0b9e04386361436eacc0be9fe6d4a8e0" + ), ] func failManifest(_ message: String) -> Never { diff --git a/examples/repo-qa/Package.swift b/examples/repo-qa/Package.swift index 8773332..247c734 100644 --- a/examples/repo-qa/Package.swift +++ b/examples/repo-qa/Package.swift @@ -23,17 +23,17 @@ struct SDKRelease { // `swift build` / CI, NOT inside Xcode), or edit `defaultSDKVersion` here. For a // release not listed, add its entry (URL + the `.sha256` next to the zip on the // GitHub release) or just replace the strings in place. -let defaultSDKVersion = "2.2.0-GA" +let defaultSDKVersion = "2.3.0-GA" let knownSDKReleases: [String: SDKRelease] = [ - "2.1.0-GA": SDKRelease( - url: "https://github.com/ancientcomputing/locallm/releases/download/v2.1.0-GA/LocalLMLabSDKCore-2.1.0-GA.xcframework.zip", - checksum: "8d541e06f81d1586c959d6159a259c759853c88b2c68385ab28de493af6c5776" - ), "2.2.0-GA": SDKRelease( url: "https://github.com/ancientcomputing/locallm/releases/download/v2.2.0-GA/LocalLMLabSDKCore-2.2.0-GA.xcframework.zip", checksum: "5efc40cae9f8a1bf53bfe03195c5365398bc9501b3e6b0feee3e5c67385537f4" ), + "2.3.0-GA": SDKRelease( + url: "https://github.com/ancientcomputing/locallm/releases/download/v2.3.0-GA/LocalLMLabSDKCore-2.3.0-GA.xcframework.zip", + checksum: "7821197d0cf5b4c92e36af5c537295e594afbb55d60d2f98b0773a3625403575" + ), ] func failManifest(_ message: String) -> Never { diff --git a/examples/security-demo/Package.swift b/examples/security-demo/Package.swift index 56d949e..26e8181 100644 --- a/examples/security-demo/Package.swift +++ b/examples/security-demo/Package.swift @@ -29,17 +29,17 @@ struct SDKRelease { // `swift build` / CI, NOT inside Xcode), or edit `defaultSDKVersion` here. For a // release not listed, add its entry (URL + the `.sha256` next to the zip on the // GitHub release) or just replace the strings in place. -let defaultSDKVersion = "2.2.0-GA" +let defaultSDKVersion = "2.3.0-GA" let knownSDKReleases: [String: SDKRelease] = [ - "2.1.0-GA": SDKRelease( - remoteURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.1.0-GA/LocalLMLabSDKRemote-2.1.0-GA.xcframework.zip", - remoteChecksum: "347257172a6da7104b7081602e9dbfc853ed5d038b5e30831aad8dfa2753ae92" - ), "2.2.0-GA": SDKRelease( remoteURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.2.0-GA/LocalLMLabSDKRemote-2.2.0-GA.xcframework.zip", remoteChecksum: "1c9af025c700bc0c41c80387c1d6a59ba2f2ab6203eb8ffd8548d61c6ac1fe38" ), + "2.3.0-GA": SDKRelease( + remoteURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.3.0-GA/LocalLMLabSDKRemote-2.3.0-GA.xcframework.zip", + remoteChecksum: "dae65d45bea196f6cd2538328fad1a0e33eceae5fac5ee965183706ac942daaf" + ), ] func failManifest(_ message: String) -> Never { diff --git a/examples/vistanova/Package.swift b/examples/vistanova/Package.swift index 2caaba2..0c36909 100644 --- a/examples/vistanova/Package.swift +++ b/examples/vistanova/Package.swift @@ -24,21 +24,21 @@ struct SDKRelease { // strings in place. Needs a release that includes `MLXModelProvider(pinnedRevisions:)` and // `cancelDownload(_:)`: the checksums below are the 1.0.0-RC.1 binaries as re-published on // 2026-09-19. An RC.1 pulled earlier fails the checksum check — re-resolve packages. -let defaultSDKVersion = "2.2.0-GA" +let defaultSDKVersion = "2.3.0-GA" let knownSDKReleases: [String: SDKRelease] = [ - "2.1.0-GA": SDKRelease( - coreURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.1.0-GA/LocalLMLabSDKCore-2.1.0-GA.xcframework.zip", - coreChecksum: "8d541e06f81d1586c959d6159a259c759853c88b2c68385ab28de493af6c5776", - inferenceURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.1.0-GA/LocalLMLabSDKInference-2.1.0-GA.xcframework.zip", - inferenceChecksum: "77d3d1a1bae9df6de5955dfa945242c177cb129b4896c95befb81db6c923656f" - ), "2.2.0-GA": SDKRelease( coreURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.2.0-GA/LocalLMLabSDKCore-2.2.0-GA.xcframework.zip", coreChecksum: "5efc40cae9f8a1bf53bfe03195c5365398bc9501b3e6b0feee3e5c67385537f4", inferenceURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.2.0-GA/LocalLMLabSDKInference-2.2.0-GA.xcframework.zip", inferenceChecksum: "afd427fb741a820f55bc0a384850ae6404608f5ad73d185a583a89e8f0d61a61" ), + "2.3.0-GA": SDKRelease( + coreURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.3.0-GA/LocalLMLabSDKCore-2.3.0-GA.xcframework.zip", + coreChecksum: "7821197d0cf5b4c92e36af5c537295e594afbb55d60d2f98b0773a3625403575", + inferenceURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.3.0-GA/LocalLMLabSDKInference-2.3.0-GA.xcframework.zip", + inferenceChecksum: "63dc8bcba5e91e1324809ca412a23ebb0b9e04386361436eacc0be9fe6d4a8e0" + ), ] func failManifest(_ message: String) -> Never { diff --git a/examples/workspace-buddy-local/Package.swift b/examples/workspace-buddy-local/Package.swift index d1a9a7f..b9642d1 100644 --- a/examples/workspace-buddy-local/Package.swift +++ b/examples/workspace-buddy-local/Package.swift @@ -28,21 +28,21 @@ struct SDKRelease { // `swift build` / CI, NOT inside Xcode), or edit `defaultSDKVersion` here. For a // release not listed, add its entry (URL + the `.sha256` next to the zip on the // GitHub release) or just replace the strings in place. -let defaultSDKVersion = "2.2.0-GA" +let defaultSDKVersion = "2.3.0-GA" let knownSDKReleases: [String: SDKRelease] = [ - "2.1.0-GA": SDKRelease( - coreURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.1.0-GA/LocalLMLabSDKCore-2.1.0-GA.xcframework.zip", - coreChecksum: "8d541e06f81d1586c959d6159a259c759853c88b2c68385ab28de493af6c5776", - inferenceURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.1.0-GA/LocalLMLabSDKInference-2.1.0-GA.xcframework.zip", - inferenceChecksum: "77d3d1a1bae9df6de5955dfa945242c177cb129b4896c95befb81db6c923656f" - ), "2.2.0-GA": SDKRelease( coreURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.2.0-GA/LocalLMLabSDKCore-2.2.0-GA.xcframework.zip", coreChecksum: "5efc40cae9f8a1bf53bfe03195c5365398bc9501b3e6b0feee3e5c67385537f4", inferenceURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.2.0-GA/LocalLMLabSDKInference-2.2.0-GA.xcframework.zip", inferenceChecksum: "afd427fb741a820f55bc0a384850ae6404608f5ad73d185a583a89e8f0d61a61" ), + "2.3.0-GA": SDKRelease( + coreURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.3.0-GA/LocalLMLabSDKCore-2.3.0-GA.xcframework.zip", + coreChecksum: "7821197d0cf5b4c92e36af5c537295e594afbb55d60d2f98b0773a3625403575", + inferenceURL: "https://github.com/ancientcomputing/locallm/releases/download/v2.3.0-GA/LocalLMLabSDKInference-2.3.0-GA.xcframework.zip", + inferenceChecksum: "63dc8bcba5e91e1324809ca412a23ebb0b9e04386361436eacc0be9fe6d4a8e0" + ), ] func failManifest(_ message: String) -> Never { diff --git a/examples/workspace-buddy-local/README.md b/examples/workspace-buddy-local/README.md index b1bb1ce..5379e84 100644 --- a/examples/workspace-buddy-local/README.md +++ b/examples/workspace-buddy-local/README.md @@ -146,10 +146,36 @@ swift-huggingface picks this path automatically for a sandboxed app (it keys off - It is **not shared** with `code-buddy` / `repo-qa-local` (those are unsandboxed and use `~/.cache/huggingface/hub/`) — each downloads its own copy. - To point somewhere else, pass `MLXModelProvider(cacheDirectory:)` or set `HF_HUB_CACHE`. +- The container is named after the bundle ID, `lab.locallm.sdk.reference.workspacebuddylocal`. + The Xcode Run build and the packaged `.app` share it, so one download serves both. +- To look at it in Finder: **Go ▸ Go to Folder…** (⇧⌘G) and paste the path. If macOS asks whether + Finder or Terminal may access another app's data, allow it. A non-sandboxed app or CLI using the same model layer stores it at `~/.cache/huggingface/hub/` instead. See [`docs/sdk-guide.md` §6a](../../docs/sdk-guide.md#6a-the-model-layer-local-models-routing-sessions). +### Removing the model + +Quit the app first, then delete the model's folder: + +```bash +rm -rf ~/Library/Containers/lab.locallm.sdk.reference.workspacebuddylocal/Data/Library/Caches/huggingface/hub/models--mlx-community--Qwen3-8B-4bit +``` + +The next run downloads it again, at the same version (the app pins it). If a download was +interrupted, the folder may hold only the small files (config, tokenizer); removing it makes the +next run start the download from scratch. + +To start completely fresh — the model and the remembered folder — quit the app and remove the whole +container. macOS creates a new one on the next launch: + +```bash +rm -rf ~/Library/Containers/lab.locallm.sdk.reference.workspacebuddylocal +``` + +If `rm` says *Operation not permitted*, macOS is protecting the app's data: allow Terminal when it +asks, or delete the folder in Finder instead. + ## What the model layer adds (diff against `workspace-buddy`) The `FolderAccess` enum (folder picker + security-scoped bookmark + `withFolderAccessAsync`) is diff --git a/examples/workspace-buddy/Package.swift b/examples/workspace-buddy/Package.swift index 2c867b2..54b613c 100644 --- a/examples/workspace-buddy/Package.swift +++ b/examples/workspace-buddy/Package.swift @@ -21,17 +21,17 @@ struct SDKRelease { // `swift build` / CI, NOT inside Xcode), or edit `defaultSDKVersion` here. For a // release not listed, add its entry (URL + the `.sha256` next to the zip on the // GitHub release) or just replace the strings in place. -let defaultSDKVersion = "2.2.0-GA" +let defaultSDKVersion = "2.3.0-GA" let knownSDKReleases: [String: SDKRelease] = [ - "2.1.0-GA": SDKRelease( - url: "https://github.com/ancientcomputing/locallm/releases/download/v2.1.0-GA/LocalLMLabSDKCore-2.1.0-GA.xcframework.zip", - checksum: "8d541e06f81d1586c959d6159a259c759853c88b2c68385ab28de493af6c5776" - ), "2.2.0-GA": SDKRelease( url: "https://github.com/ancientcomputing/locallm/releases/download/v2.2.0-GA/LocalLMLabSDKCore-2.2.0-GA.xcframework.zip", checksum: "5efc40cae9f8a1bf53bfe03195c5365398bc9501b3e6b0feee3e5c67385537f4" ), + "2.3.0-GA": SDKRelease( + url: "https://github.com/ancientcomputing/locallm/releases/download/v2.3.0-GA/LocalLMLabSDKCore-2.3.0-GA.xcframework.zip", + checksum: "7821197d0cf5b4c92e36af5c537295e594afbb55d60d2f98b0773a3625403575" + ), ] func failManifest(_ message: String) -> Never { diff --git a/toolkit/README.md b/toolkit/README.md index e7e3696..9b308f6 100644 --- a/toolkit/README.md +++ b/toolkit/README.md @@ -16,24 +16,24 @@ Starting with `1.0.0-beta.4`, the toolkit is shipped as release assets on **[github.com/ancientcomputing/locallm](https://github.com/ancientcomputing/locallm/releases)**, alongside the LocalLM Lab app DMG and the SDK xcframeworks. -Latest (`2.2.0-GA`): +Latest (`2.3.0-GA`): ```bash -curl -LO https://github.com/ancientcomputing/locallm/releases/download/v2.2.0-GA/localai-toolkit-2.2.0-GA-arm64.zip -curl -LO https://github.com/ancientcomputing/locallm/releases/download/v2.2.0-GA/localai-toolkit-2.2.0-GA-arm64.zip.sha256 +curl -LO https://github.com/ancientcomputing/locallm/releases/download/v2.3.0-GA/localai-toolkit-2.3.0-GA-arm64.zip +curl -LO https://github.com/ancientcomputing/locallm/releases/download/v2.3.0-GA/localai-toolkit-2.3.0-GA-arm64.zip.sha256 ``` ## Verify ```bash -shasum -a 256 -c localai-toolkit-2.2.0-GA-arm64.zip.sha256 +shasum -a 256 -c localai-toolkit-2.3.0-GA-arm64.zip.sha256 ``` ## Install ```bash -unzip localai-toolkit-2.2.0-GA-arm64.zip +unzip localai-toolkit-2.3.0-GA-arm64.zip ``` This produces `localai-cli`, `localai-playground-run` (macOS 27),