From 6c0a01dc909e511b26c1806716c9a9010bd8b216 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 19:48:21 +0000 Subject: [PATCH] fix: bundle mac model catalog --- .../Sources/Clawdbot/ModelCatalogLoader.swift | 95 +++++++++++- package.json | 3 - patches/@mariozechner__pi-ai@0.49.2.patch | 135 ------------------ pnpm-lock.yaml | 13 +- scripts/package-mac-app.sh | 9 ++ 5 files changed, 104 insertions(+), 151 deletions(-) delete mode 100644 patches/@mariozechner__pi-ai@0.49.2.patch diff --git a/apps/macos/Sources/Clawdbot/ModelCatalogLoader.swift b/apps/macos/Sources/Clawdbot/ModelCatalogLoader.swift index 48001b4e9..2f1c75fe6 100644 --- a/apps/macos/Sources/Clawdbot/ModelCatalogLoader.swift +++ b/apps/macos/Sources/Clawdbot/ModelCatalogLoader.swift @@ -2,14 +2,28 @@ import Foundation import JavaScriptCore enum ModelCatalogLoader { - static let defaultPath: String = FileManager().homeDirectoryForCurrentUser - .appendingPathComponent("Projects/pi-mono/packages/ai/src/models.generated.ts").path + static var defaultPath: String { self.resolveDefaultPath() } private static let logger = Logger(subsystem: "com.clawdbot", category: "models") + private nonisolated static let appSupportDir: URL = { + let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + return base.appendingPathComponent("Clawdbot", isDirectory: true) + }() + + private static var cachePath: URL { + self.appSupportDir.appendingPathComponent("model-catalog/models.generated.js", isDirectory: false) + } static func load(from path: String) async throws -> [ModelChoice] { let expanded = (path as NSString).expandingTildeInPath - self.logger.debug("model catalog load start file=\(URL(fileURLWithPath: expanded).lastPathComponent)") - let source = try String(contentsOfFile: expanded, encoding: .utf8) + guard let resolved = self.resolvePath(preferred: expanded) else { + self.logger.error("model catalog load failed: file not found") + throw NSError( + domain: "ModelCatalogLoader", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Model catalog file not found"]) + } + self.logger.debug("model catalog load start file=\(URL(fileURLWithPath: resolved.path).lastPathComponent)") + let source = try String(contentsOfFile: resolved.path, encoding: .utf8) let sanitized = self.sanitize(source: source) let ctx = JSContext() @@ -45,9 +59,82 @@ enum ModelCatalogLoader { return lhs.provider.localizedCaseInsensitiveCompare(rhs.provider) == .orderedAscending } self.logger.debug("model catalog loaded providers=\(rawModels.count) models=\(sorted.count)") + if resolved.shouldCache { + self.cacheCatalog(sourcePath: resolved.path) + } return sorted } + private static func resolveDefaultPath() -> String { + let cache = self.cachePath.path + if FileManager().isReadableFile(atPath: cache) { return cache } + if let bundlePath = self.bundleCatalogPath() { return bundlePath } + if let nodePath = self.nodeModulesCatalogPath() { return nodePath } + return cache + } + + private static func resolvePath(preferred: String) -> (path: String, shouldCache: Bool)? { + if FileManager().isReadableFile(atPath: preferred) { + return (preferred, preferred != self.cachePath.path) + } + + if let bundlePath = self.bundleCatalogPath(), bundlePath != preferred { + self.logger.warning("model catalog path missing; falling back to bundled catalog") + return (bundlePath, true) + } + + let cache = self.cachePath.path + if cache != preferred, FileManager().isReadableFile(atPath: cache) { + self.logger.warning("model catalog path missing; falling back to cached catalog") + return (cache, false) + } + + if let nodePath = self.nodeModulesCatalogPath(), nodePath != preferred { + self.logger.warning("model catalog path missing; falling back to node_modules catalog") + return (nodePath, true) + } + + return nil + } + + private static func bundleCatalogPath() -> String? { + guard let url = Bundle.main.url(forResource: "models.generated", withExtension: "js") else { + return nil + } + return url.path + } + + private static func nodeModulesCatalogPath() -> String? { + let roots = [ + URL(fileURLWithPath: CommandResolver.projectRootPath()), + URL(fileURLWithPath: FileManager().currentDirectoryPath), + ] + for root in roots { + let candidate = root + .appendingPathComponent("node_modules/@mariozechner/pi-ai/dist/models.generated.js") + if FileManager().isReadableFile(atPath: candidate.path) { + return candidate.path + } + } + return nil + } + + private static func cacheCatalog(sourcePath: String) { + let destination = self.cachePath + do { + try FileManager().createDirectory( + at: destination.deletingLastPathComponent(), + withIntermediateDirectories: true) + if FileManager().fileExists(atPath: destination.path) { + try FileManager().removeItem(at: destination) + } + try FileManager().copyItem(atPath: sourcePath, toPath: destination.path) + self.logger.debug("model catalog cached file=\(destination.lastPathComponent)") + } catch { + self.logger.warning("model catalog cache failed: \(error.localizedDescription)") + } + } + private static func sanitize(source: String) -> String { guard let exportRange = source.range(of: "export const MODELS"), let firstBrace = source[exportRange.upperBound...].firstIndex(of: "{"), diff --git a/package.json b/package.json index cdea28b6f..9de1783ca 100644 --- a/package.json +++ b/package.json @@ -236,9 +236,6 @@ "@sinclair/typebox": "0.34.47", "hono": "4.11.4", "tar": "7.5.4" - }, - "patchedDependencies": { - "@mariozechner/pi-ai@0.49.2": "patches/@mariozechner__pi-ai@0.49.2.patch" } }, "vitest": { diff --git a/patches/@mariozechner__pi-ai@0.49.2.patch b/patches/@mariozechner__pi-ai@0.49.2.patch deleted file mode 100644 index 57fb965f8..000000000 --- a/patches/@mariozechner__pi-ai@0.49.2.patch +++ /dev/null @@ -1,135 +0,0 @@ -diff --git a/dist/providers/anthropic.js b/dist/providers/anthropic.js -index 1cba2f1365812fd2f88993009c9cc06e9c348279..664dd6d8b400ec523fb735480741b9ad64f9a68c 100644 ---- a/dist/providers/anthropic.js -+++ b/dist/providers/anthropic.js -@@ -298,10 +298,11 @@ function createClient(model, apiKey, interleavedThinking) { - }); - return { client, isOAuthToken: true }; - } -+ const apiBetaFeatures = ["extended-cache-ttl-2025-04-11", ...betaFeatures]; - const defaultHeaders = { - accept: "application/json", - "anthropic-dangerous-direct-browser-access": "true", -- "anthropic-beta": betaFeatures.join(","), -+ "anthropic-beta": apiBetaFeatures.join(","), - ...(model.headers || {}), - }; - const client = new Anthropic({ -@@ -313,9 +314,11 @@ function createClient(model, apiKey, interleavedThinking) { - return { client, isOAuthToken: false }; - } - function buildParams(model, context, isOAuthToken, options) { -+ const cacheControlTtl = !isOAuthToken ? (options?.cacheControlTtl ?? "1h") : undefined; -+ const cacheControl = cacheControlTtl ? { type: "ephemeral", ttl: cacheControlTtl } : { type: "ephemeral" }; - const params = { - model: model.id, -- messages: convertMessages(context.messages, model, isOAuthToken), -+ messages: convertMessages(context.messages, model, isOAuthToken, cacheControl), - max_tokens: options?.maxTokens || (model.maxTokens / 3) | 0, - stream: true, - }; -@@ -325,18 +328,14 @@ function buildParams(model, context, isOAuthToken, options) { - { - type: "text", - text: "You are Claude Code, Anthropic's official CLI for Claude.", -- cache_control: { -- type: "ephemeral", -- }, -+ cache_control: cacheControl, - }, - ]; - if (context.systemPrompt) { - params.system.push({ - type: "text", - text: sanitizeSurrogates(context.systemPrompt), -- cache_control: { -- type: "ephemeral", -- }, -+ cache_control: cacheControl, - }); - } - } -@@ -346,9 +345,7 @@ function buildParams(model, context, isOAuthToken, options) { - { - type: "text", - text: sanitizeSurrogates(context.systemPrompt), -- cache_control: { -- type: "ephemeral", -- }, -+ cache_control: cacheControl, - }, - ]; - } -@@ -378,7 +375,7 @@ function buildParams(model, context, isOAuthToken, options) { - function normalizeToolCallId(id) { - return id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64); - } --function convertMessages(messages, model, isOAuthToken) { -+function convertMessages(messages, model, isOAuthToken, cacheControl) { - const params = []; - // Transform messages for cross-provider compatibility - const transformedMessages = transformMessages(messages, model, normalizeToolCallId); -@@ -514,7 +511,7 @@ function convertMessages(messages, model, isOAuthToken) { - const lastBlock = lastMessage.content[lastMessage.content.length - 1]; - if (lastBlock && - (lastBlock.type === "text" || lastBlock.type === "image" || lastBlock.type === "tool_result")) { -- lastBlock.cache_control = { type: "ephemeral" }; -+ lastBlock.cache_control = cacheControl; - } - } - } -diff --git a/dist/providers/openai-completions.js b/dist/providers/openai-completions.js -index ee5c88d8e280ceeff45ed075f2c7357d40005578..89daad7b0e53753e094028291226d32da9446440 100644 ---- a/dist/providers/openai-completions.js -+++ b/dist/providers/openai-completions.js -@@ -305,7 +305,7 @@ function createClient(model, context, apiKey) { - function buildParams(model, context, options) { - const compat = getCompat(model); - const messages = convertMessages(model, context, compat); -- maybeAddOpenRouterAnthropicCacheControl(model, messages); -+ maybeAddOpenRouterAnthropicCacheControl(model, messages, options?.cacheControlTtl); - const params = { - model: model.id, - messages, -@@ -349,9 +349,10 @@ function buildParams(model, context, options) { - } - return params; - } --function maybeAddOpenRouterAnthropicCacheControl(model, messages) { -+function maybeAddOpenRouterAnthropicCacheControl(model, messages, cacheControlTtl) { - if (model.provider !== "openrouter" || !model.id.startsWith("anthropic/")) - return; -+ const cacheControl = cacheControlTtl ? { type: "ephemeral", ttl: cacheControlTtl } : { type: "ephemeral" }; - // Anthropic-style caching requires cache_control on a text part. Add a breakpoint - // on the last user/assistant message (walking backwards until we find text content). - for (let i = messages.length - 1; i >= 0; i--) { -@@ -361,7 +362,7 @@ function maybeAddOpenRouterAnthropicCacheControl(model, messages) { - const content = msg.content; - if (typeof content === "string") { - msg.content = [ -- Object.assign({ type: "text", text: content }, { cache_control: { type: "ephemeral" } }), -+ Object.assign({ type: "text", text: content }, { cache_control: cacheControl }), - ]; - return; - } -@@ -371,7 +372,7 @@ function maybeAddOpenRouterAnthropicCacheControl(model, messages) { - for (let j = content.length - 1; j >= 0; j--) { - const part = content[j]; - if (part?.type === "text") { -- Object.assign(part, { cache_control: { type: "ephemeral" } }); -+ Object.assign(part, { cache_control: cacheControl }); - return; - } - } -diff --git a/dist/stream.js b/dist/stream.js -index d23fdd9f226a949fac4f2c7160af76f7f5fe71d1..3500f074bd88b85f4c7dd9bf42279f80fdf264d1 100644 ---- a/dist/stream.js -+++ b/dist/stream.js -@@ -146,6 +146,7 @@ function mapOptionsForApi(model, options, apiKey) { - signal: options?.signal, - apiKey: apiKey || options?.apiKey, - sessionId: options?.sessionId, -+ cacheControlTtl: options?.cacheControlTtl, - }; - // Helper to clamp xhigh to high for providers that don't support it - const clampReasoning = (effort) => (effort === "xhigh" ? "high" : effort); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6ab92034..e0a669c0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,11 +9,6 @@ overrides: hono: 4.11.4 tar: 7.5.4 -patchedDependencies: - '@mariozechner/pi-ai@0.49.2': - hash: 4ae0a92a4b2c74703711e2a62b745ca8af6a9948ea7fa923097e875c76354d7e - path: patches/@mariozechner__pi-ai@0.49.2.patch - importers: .: @@ -44,7 +39,7 @@ importers: version: 0.49.2(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-ai': specifier: 0.49.2 - version: 0.49.2(patch_hash=4ae0a92a4b2c74703711e2a62b745ca8af6a9948ea7fa923097e875c76354d7e)(ws@8.19.0)(zod@4.3.5) + version: 0.49.2(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-coding-agent': specifier: 0.49.2 version: 0.49.2(ws@8.19.0)(zod@4.3.5) @@ -6265,7 +6260,7 @@ snapshots: '@mariozechner/pi-agent-core@0.49.2(ws@8.19.0)(zod@4.3.5)': dependencies: - '@mariozechner/pi-ai': 0.49.2(patch_hash=4ae0a92a4b2c74703711e2a62b745ca8af6a9948ea7fa923097e875c76354d7e)(ws@8.19.0)(zod@4.3.5) + '@mariozechner/pi-ai': 0.49.2(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-tui': 0.49.2 transitivePeerDependencies: - '@modelcontextprotocol/sdk' @@ -6276,7 +6271,7 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.49.2(patch_hash=4ae0a92a4b2c74703711e2a62b745ca8af6a9948ea7fa923097e875c76354d7e)(ws@8.19.0)(zod@4.3.5)': + '@mariozechner/pi-ai@0.49.2(ws@8.19.0)(zod@4.3.5)': dependencies: '@anthropic-ai/sdk': 0.71.2(zod@4.3.5) '@aws-sdk/client-bedrock-runtime': 3.971.0 @@ -6303,7 +6298,7 @@ snapshots: '@mariozechner/clipboard': 0.3.0 '@mariozechner/jiti': 2.6.5 '@mariozechner/pi-agent-core': 0.49.2(ws@8.19.0)(zod@4.3.5) - '@mariozechner/pi-ai': 0.49.2(patch_hash=4ae0a92a4b2c74703711e2a62b745ca8af6a9948ea7fa923097e875c76354d7e)(ws@8.19.0)(zod@4.3.5) + '@mariozechner/pi-ai': 0.49.2(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-tui': 0.49.2 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 diff --git a/scripts/package-mac-app.sh b/scripts/package-mac-app.sh index 41260013e..f0cc385b3 100755 --- a/scripts/package-mac-app.sh +++ b/scripts/package-mac-app.sh @@ -207,6 +207,15 @@ echo "📦 Copying device model resources" rm -rf "$APP_ROOT/Contents/Resources/DeviceModels" cp -R "$ROOT_DIR/apps/macos/Sources/Clawdbot/Resources/DeviceModels" "$APP_ROOT/Contents/Resources/DeviceModels" +echo "📦 Copying model catalog" +MODEL_CATALOG_SRC="$ROOT_DIR/node_modules/@mariozechner/pi-ai/dist/models.generated.js" +MODEL_CATALOG_DEST="$APP_ROOT/Contents/Resources/models.generated.js" +if [ -f "$MODEL_CATALOG_SRC" ]; then + cp "$MODEL_CATALOG_SRC" "$MODEL_CATALOG_DEST" +else + echo "WARN: model catalog missing at $MODEL_CATALOG_SRC (continuing)" >&2 +fi + echo "📦 Copying ClawdbotKit resources" CLAWDBOTKIT_BUNDLE="$(build_path_for_arch "$PRIMARY_ARCH")/$BUILD_CONFIG/ClawdbotKit_ClawdbotKit.bundle" if [ -d "$CLAWDBOTKIT_BUNDLE" ]; then