fix: bundle mac model catalog

This commit is contained in:
Peter Steinberger
2026-01-21 19:48:21 +00:00
parent 41c9c214fc
commit 6c0a01dc90
5 changed files with 104 additions and 151 deletions

View File

@@ -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: "{"),

View File

@@ -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": {

View File

@@ -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);

13
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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