Swift SDK

Swift SDK は models.manashiki.com API をラップし、カタログの 一覧取得、署名付きダウンロード URL の解決、アーティファクトのディスクへの ストリーミング、SHA-256 の検証までを async/await で提供します。 read-only の SwiftPM ミラー Nozium/manashiki-models-swift から配布しています。バグ報告・サポートは contact@utakata-scifi.com までお願いします。

1. SwiftPM でインストール

// Package.swift — 最初のバージョンタグが公開されるまでは main を追跡
dependencies: [
    .package(url: "https://github.com/Nozium/manashiki-models-swift", branch: "main"),
]

Xcode からは File → Add Package Dependencieshttps://github.com/Nozium/manashiki-models-swift を貼り付け、 依存ルールに Branch: main を選択してください(最初の semver タグが出たらバージョンルールが使えます)。ミラーへの PR は branch protection でブロックされるため作成しないでください。CI が monorepo から同期しています。

要件
iOS17+
macOS14+
visionOS1+
tvOS17+
Swift tools5.9
依存ライブラリswift-crypto 3.0.0+(利用可能な環境では CryptoKit を使用)

2. 正しい API キーを選ぶ

SDK はイニシャライザでキーのフォーマットをローカル検証し、ネットワーク 通信の前に APIError.invalidAPIKey を投げます。キーはすべての リクエストで X-API-Key ヘッダーとして送信されます。配布 バイナリに埋め込んでよいキー種別は 1 つだけです:

PrefixTier置き場所
mk_pub_<org>_…clientアプリバンドル。埋め込み可能な唯一の mk_* 種別。
mk_test_<org>_…server (test 環境)ステージング / 開発ツール用。配布ビルドには含めない。
mk_live_<org>_…server本番バックエンド専用。
mk_svc_<org>_…serverエージェントと CI 用。
pk_live_* / pk_test_*client (legacy)旧来のインテグレーション。引き続き利用可。
sk_live_* / sk_test_*server (legacy)アプリバイナリには絶対に含めない。

サーバー専用メソッド — createSession(principalId:ttlSec:)POST /api/session)と createModelUpload(_:)POST /api/uploads/model) — を client tier のキーで呼ぶと APIError.forbiddenScope が 投げられます。漏洩した mk_pub_* キーでセッションの発行や アーティファクトのアップロードはできません。

3. クライアントの初期化

import Manashiki

// 不正なキーはネットワーク通信の前に APIError.invalidAPIKey を投げます。
let client = try ManashikiClient(apiKey: "mk_pub_acme_...")

// Full initializer:
// init(apiKey: String,
//      baseURL: URL = URL(string: "https://models.manashiki.com")!,
//      transport: any HTTPTransport = URLSessionTransport(),
//      storageDirectory: URL? = nil) throws

ManashikiClientSendable な struct なので、 1 つのインスタンスを複数のタスク間で共有できます。 HTTPTransport はプロトコルです。ユニットテストでは モックの transport を注入してください。

4. モデルの一覧と詳細取得

// func listModels() async throws -> [ModelEntry]      (GET /api/models)
let entries = try await client.listModels()
for entry in entries {
    print(entry.modelId, entry.version, entry.size, entry.sha256)
}

// func getModel(_ modelId: String) async throws -> ModelRecord
// (GET /api/models/:modelId)
let record = try await client.getModel("whisper-medical-ja-fp16")
print(record.lifecycle.status, record.compatibility.minOs ?? "-")

ModelEntrymodelIdversionsizesha256 を持ちます。 ModelRecord はレジストリレコードの全体を含みます: モデル カード、lineage、フォーマット、互換性情報(プラットフォーム、 min_os、RAM / ディスク要件)、lifecycle ステータス (draft / staging / production / deprecated / sunset)、 公開モード、任意のベンチマークメトリクスです。listModels() にはまだフィルタリング用のパラメータがないため、返ってきた配列を record.compatibility.platforms などでフィルタしてください。

5. 進捗付きダウンロードと SHA-256 検証

// func downloadModel(
//     _ modelId: String,
//     session: String? = nil,
//     progress: ((Int64, Int64?) -> Void)? = nil
// ) async throws -> DownloadedModel

let model = try await client.downloadModel("whisper-medical-ja-fp16") { written, total in
    if let total {
        print("\(written) / \(total) bytes")
    }
}
print(model.localURL)   // .../Application Support/ManashikiModels/whisper-medical-ja-fp16.bin
print(model.sha256)     // レジストリと照合済みの hex digest

内部で行われる処理:

  1. GET /api/download/:modelId が短命の署名付き URL(downloadUrlsha256sizeexpiresIn)を返します。
  2. アーティファクトは 1 MiB チャンクでディスクにストリーミングされます。progress クロージャは位置引数 (bytesWritten, totalBytes?) を受け取り、サーバーが content length を返さない場合 totalBytesnil です。
  3. ファイルの SHA-256 は 1 MiB チャンクのストリーミングで計算され(アーティファクト全体をメモリに載せません)、レジストリのダイジェストと比較されます。不一致なら APIError.integrityMismatch(expected:actual:) が投げられます。検証がスキップされるのは、サーバーがダイジェストを返さない場合のみです。

注意: ミラーの README には旧計画の API (progress.bytesWrittendownloadPrivateModel)が 記載されていますが、実際にリリースされている API は上記のものです。 private アセットも同じ downloadModelsession: パラメータを渡して取得します。

private(org-private)モデル

// 1. legacy の sk_live_* サーバーキーを持つバックエンドがセッショントークンを
//    発行: client.createSession(principalId:ttlSec:) → POST /api/session
//    (worker はセッション発行に mk_live_*/mk_svc_* をまだ受け付けません)
// 2. アプリがトークンを渡すと、SDK が GET /api/download/:modelId に
//    Authorization: Bearer <token> として付与します。
let session = try await myBackend.fetchModelSession()
let lora = try await client.downloadModel(
    "doctor-abc123-lora-whisper-decoder-v3",
    session: session
)

セッショントークンなしで private アセットを要求すると APIError.sessionRequired で失敗します。

6. モデルの保存先

7. ハンドリングすべきエラー

Case発生条件
invalidAPIKey / missingAPIKey不正な形式のキー(init 時にローカルで throw)、またはサーバー側でキーが拒否・欠落。
modelNotFound(String)不明なモデル id。
modelSunset(replacement:)sunset 済みのモデル。代替モデルがある場合はその id を含みます。
sessionRequiredセッショントークンなしで private アセットを要求。
forbiddenScope / forbiddenOrgキーに scope が不足、またはモデルが別 org に属している。
rateLimited(retryAfter:)レート制限に到達。サーバーが返す場合、retryAfter は秒単位。
integrityMismatch(expected:actual:)ローカルの SHA-256 がレジストリのダイジェストと不一致。
http / transportその他の HTTP ステータスやネットワーク障害。

8. 再インストール後の復元

モデルは「購入物」ではなくコンテンツです。利用権 — first-party カタログへのアクセス、自 org の private namespace、community モデル — はすべての GET /api/download/:modelId ごとに、org の API キー(private アセットの場合はセッショントークンも)に対して サーバー側で評価されます。SDK はデバイス上に購入状態を保持せず、 StoreKit で復元するものはありません。再インストール後は downloadModel を呼び直すだけで保存ディレクトリも再作成 されます。課金面では、mk_test_* のダウンロードは ステージングのクォータに計上され、mk_live_* / mk_svc_* のダウンロードは本番 DL としてメータリング されます。mk_pub_* の解決はレート制限と監査ログの 対象ですが、現時点では課金されません。

9. SwiftUI のエンドツーエンド例

import SwiftUI
import Manashiki

@MainActor @Observable
final class CatalogStore {
    var models: [ModelEntry] = []
    var progress: Double = 0
    var status = ""

    // プレースホルダーは形式検証を通ります (ランダム部は 20 文字以上) — 実際のキーに差し替えてください。
    private let client = try! ManashikiClient(apiKey: "mk_pub_acme_xxxxxxxxxxxxxxxxxxxxxxxx")

    func load() async {
        do { models = try await client.listModels() }
        catch { status = "Catalog error: \(error)" }
    }

    func download(_ modelId: String) async {
        status = "Downloading \(modelId)..."
        do {
            let model = try await client.downloadModel(modelId) { written, total in
                guard let total else { return }
                Task { @MainActor in self.progress = Double(written) / Double(total) }
            }
            status = "Verified \(model.sha256.prefix(8)) at \(model.localURL.lastPathComponent)"
        } catch APIError.integrityMismatch(let expected, let actual) {
            status = "Integrity mismatch: expected \(expected.prefix(8)), got \(actual.prefix(8))"
        } catch APIError.rateLimited(let retry) {
            status = "Rate limited, retry in \(retry ?? 60)s"
        } catch {
            status = "Download failed: \(error)"
        }
    }
}

struct CatalogView: View {
    @State private var store = CatalogStore()

    var body: some View {
        List(store.models, id: \.modelId) { entry in
            Button(entry.modelId) {
                Task { await store.download(entry.modelId) }
            }
        }
        .task { await store.load() }
        .safeAreaInset(edge: .bottom) {
            VStack {
                ProgressView(value: store.progress)
                Text(store.status).font(.caption)
            }.padding()
        }
    }
}

プレースホルダーをダッシュボードで取得した org の publishable キーに差し替え、カタログから モデル id を選んでください。