Compare commits

..
Author SHA1 Message Date
Peter Steinberger c50b64e298 docs: add changelog entry for cron tool fix 2026-01-04 16:16:20 +00:00
Clawd a471f2debe fix(cron): pass 'id' instead of 'jobId' to gateway
The cron tool was passing { jobId } to the gateway for update/remove/run/runs
actions, but the gateway protocol schema expects { id }. This caused validation
errors when trying to update or remove cron jobs via the tool.

Fixes the parameter name while keeping the external tool API unchanged (still
accepts 'jobId' from callers).
2026-01-04 16:15:28 +00:00
36 changed files with 233 additions and 2283 deletions

No files matched your search

-2
View File
@@ -5,7 +5,6 @@
## Unreleased
### Fixes
- Android: tapping the foreground service notification brings the app to the front. (#179) — thanks @Syhids
- Cron tool passes `id` to the gateway for update/remove/run/runs (keeps `jobId` input). (#180) — thanks @adamgall
@@ -17,7 +16,6 @@
- Gateway quality-of-life: config hot reload, port config support, and Control UI base paths.
- Sandbox additions: per-session Docker sandbox with hardened limits + optional sandboxed Chromium.
- New node capability: `location.get` across macOS/iOS/Android (CLI + tools).
- Models CLI: scan OpenRouter free models (tools/images), manage aliases/fallbacks, and show last-used model in status.
### Breaking
- Tool names drop the `clawdbot_` prefix (`browser`, `canvas`, `nodes`, `cron`, `gateway`).
Submodule Peekaboo updated: c1243a7978...5c8eedd642.
-5
View File
@@ -48,10 +48,6 @@ android {
lint {
disable += setOf("IconLauncherShape")
}
testOptions {
unitTests.isIncludeAndroidResources = true
}
}
kotlin {
@@ -100,7 +96,6 @@ dependencies {
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7")
testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7")
testImplementation("org.robolectric:robolectric:4.16")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.13.3")
}
@@ -98,31 +98,14 @@ class NodeForegroundService : Service() {
}
private fun buildNotification(title: String, text: String): Notification {
val launchIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val launchPending =
PendingIntent.getActivity(
this,
1,
launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val stopIntent = Intent(this, NodeForegroundService::class.java).setAction(ACTION_STOP)
val stopPending =
PendingIntent.getService(
this,
2,
stopIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
val stopPending = PendingIntent.getService(this, 2, stopIntent, flags)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(text)
.setContentIntent(launchPending)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
@@ -1,43 +0,0 @@
package com.clawdbot.android
import android.app.Notification
import android.content.Intent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class NodeForegroundServiceTest {
@Test
fun buildNotificationSetsLaunchIntent() {
val service = Robolectric.buildService(NodeForegroundService::class.java).get()
val notification = buildNotification(service)
val pendingIntent = notification.contentIntent
assertNotNull(pendingIntent)
val savedIntent = Shadows.shadowOf(pendingIntent).savedIntent
assertNotNull(savedIntent)
assertEquals(MainActivity::class.java.name, savedIntent.component?.className)
val expectedFlags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
assertEquals(expectedFlags, savedIntent.flags and expectedFlags)
}
private fun buildNotification(service: NodeForegroundService): Notification {
val method =
NodeForegroundService::class.java.getDeclaredMethod(
"buildNotification",
String::class.java,
String::class.java,
)
method.isAccessible = true
return method.invoke(service, "Title", "Text") as Notification
}
}
@@ -55,7 +55,7 @@ final class ScreenRecordService: @unchecked Sendable {
outPath: outPath)
let state = CaptureState()
let recordQueue = DispatchQueue(label: "com.clawdbot.screenrecord")
let recordQueue = DispatchQueue(label: "com.clawdis.screenrecord")
try await self.startCapture(state: state, config: config, recordQueue: recordQueue)
try await Task.sleep(nanoseconds: UInt64(config.durationMs) * 1_000_000)
+1 -2
View File
@@ -9,7 +9,7 @@ import SwiftUI
final class AppState {
private let isPreview: Bool
private var isInitializing = true
private var configWatcher: ConfigFileWatcher?
private nonisolated var configWatcher: ConfigFileWatcher?
private var suppressVoiceWakeGlobalSync = false
private var voiceWakeGlobalSyncTask: Task<Void, Never>?
@@ -321,7 +321,6 @@ final class AppState {
}
}
@MainActor
deinit {
self.configWatcher?.stop()
}
@@ -11,6 +11,9 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
private var locationContinuation: CheckedContinuation<CLLocation, Swift.Error>?
private struct UncheckedSendable<T>: @unchecked Sendable {
let value: T
}
override init() {
super.init()
@@ -60,7 +63,7 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
}
}
private func withTimeout<T: Sendable>(
private func withTimeout<T>(
timeoutMs: Int,
operation: @escaping () async throws -> T) async throws -> T
{
@@ -68,38 +71,15 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
return try await operation()
}
return try await withCheckedThrowingContinuation { continuation in
var didFinish = false
func finish(returning value: T) {
guard !didFinish else { return }
didFinish = true
continuation.resume(returning: value)
}
func finish(throwing error: Swift.Error) {
guard !didFinish else { return }
didFinish = true
continuation.resume(throwing: error)
}
let timeoutItem = DispatchWorkItem {
finish(throwing: Error.timeout)
}
DispatchQueue.main.asyncAfter(
deadline: .now() + .milliseconds(timeoutMs),
execute: timeoutItem)
Task { @MainActor in
do {
let value = try await operation()
timeoutItem.cancel()
finish(returning: value)
} catch {
timeoutItem.cancel()
finish(throwing: error)
}
return try await withThrowingTaskGroup(of: UncheckedSendable<T>.self) { group in
group.addTask { try await UncheckedSendable(value: operation()) }
group.addTask {
try await Task.sleep(nanoseconds: UInt64(timeoutMs) * 1_000_000)
throw Error.timeout
}
let result = try await group.next()!
group.cancelAll()
return result.value
}
}
@@ -86,7 +86,7 @@ extension OnboardingView {
var navigationBar: some View {
let wizardLockIndex = self.wizardPageOrderIndex
return HStack(spacing: 20) {
HStack(spacing: 20) {
ZStack(alignment: .leading) {
Button(action: {}, label: {
Label("Back", systemImage: "chevron.left").labelStyle(.iconOnly)
@@ -1,4 +1,3 @@
import ClawdbotProtocol
import Observation
import SwiftUI
@@ -232,52 +232,44 @@ struct OnboardingWizardStepView: View {
private var selectOptions: some View {
VStack(alignment: .leading, spacing: 8) {
ForEach(self.optionItems, id: \.index) { item in
self.selectOptionRow(item)
ForEach(self.optionItems) { item in
Button {
self.selectedIndex = item.index
} label: {
HStack(alignment: .top, spacing: 8) {
Image(systemName: self.selectedIndex == item.index ? "largecircle.fill.circle" : "circle")
.foregroundStyle(.accent)
VStack(alignment: .leading, spacing: 2) {
Text(item.option.label)
.foregroundStyle(.primary)
if let hint = item.option.hint, !hint.isEmpty {
Text(hint)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
.buttonStyle(.plain)
}
}
}
private var multiselectOptions: some View {
VStack(alignment: .leading, spacing: 8) {
ForEach(self.optionItems, id: \.index) { item in
self.multiselectOptionRow(item)
}
}
}
private func selectOptionRow(_ item: WizardOptionItem) -> some View {
Button {
self.selectedIndex = item.index
} label: {
HStack(alignment: .top, spacing: 8) {
Image(systemName: self.selectedIndex == item.index ? "largecircle.fill.circle" : "circle")
.foregroundStyle(Color.accentColor)
VStack(alignment: .leading, spacing: 2) {
Text(item.option.label)
.foregroundStyle(.primary)
if let hint = item.option.hint, !hint.isEmpty {
Text(hint)
.font(.caption)
.foregroundStyle(.secondary)
ForEach(self.optionItems) { item in
Toggle(isOn: self.bindingForOption(item)) {
VStack(alignment: .leading, spacing: 2) {
Text(item.option.label)
if let hint = item.option.hint, !hint.isEmpty {
Text(hint)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
.buttonStyle(.plain)
}
private func multiselectOptionRow(_ item: WizardOptionItem) -> some View {
Toggle(isOn: self.bindingForOption(item)) {
VStack(alignment: .leading, spacing: 2) {
Text(item.option.label)
if let hint = item.option.hint, !hint.isEmpty {
Text(hint)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
private func bindingForOption(_ item: WizardOptionItem) -> Binding<Bool> {
@@ -588,9 +588,7 @@ actor TalkModeRuntime {
let stream = client.streamSynthesize(voiceId: voiceId, request: request)
guard self.isCurrent(input.generation) else { return }
if self.interruptOnSpeech {
guard await self.prepareForPlayback(generation: input.generation) else { return }
}
if self.interruptOnSpeech, ! await self.prepareForPlayback(generation: input.generation) { return }
await MainActor.run { TalkModeController.shared.updatePhase(.speaking) }
self.phase = .speaking
@@ -645,9 +643,7 @@ actor TalkModeRuntime {
private func playSystemVoice(input: TalkPlaybackInput) async throws {
self.ttsLogger.info("talk system voice start chars=\(input.cleanedText.count, privacy: .public)")
if self.interruptOnSpeech {
guard await self.prepareForPlayback(generation: input.generation) else { return }
}
if self.interruptOnSpeech, ! await self.prepareForPlayback(generation: input.generation) { return }
await MainActor.run { TalkModeController.shared.updatePhase(.speaking) }
self.phase = .speaking
await TalkSystemSpeechSynthesizer.shared.stop()
@@ -731,6 +727,7 @@ actor TalkModeRuntime {
}
extension TalkModeRuntime {
// MARK: - Audio playback (MainActor helpers)
@MainActor
@@ -875,7 +875,7 @@ public struct WizardStep: Codable {
}
}
public struct WizardNextResult: Codable, Sendable {
public struct WizardNextResult: Codable {
public let done: Bool
public let step: [String: AnyCodable]?
public let status: AnyCodable?
@@ -900,7 +900,7 @@ public struct WizardNextResult: Codable, Sendable {
}
}
public struct WizardStartResult: Codable, Sendable {
public struct WizardStartResult: Codable {
public let sessionid: String
public let done: Bool
public let step: [String: AnyCodable]?
@@ -929,7 +929,7 @@ public struct WizardStartResult: Codable, Sendable {
}
}
public struct WizardStatusResult: Codable, Sendable {
public struct WizardStatusResult: Codable {
public let status: AnyCodable
public let error: String?
-5
View File
@@ -430,7 +430,6 @@ Controls the embedded agent runtime (model/thinking/verbose/timeouts).
`allowedModels` lets `/model` list/filter and enforce a per-session allowlist
(omit to show the full catalog).
`modelAliases` adds short names for `/model` (alias -> provider/model).
`modelFallbacks` lists ordered fallback models to try when the default fails.
```json5
{
@@ -444,10 +443,6 @@ Controls the embedded agent runtime (model/thinking/verbose/timeouts).
Opus: "anthropic/claude-opus-4-5",
Sonnet: "anthropic/claude-sonnet-4-1"
},
modelFallbacks: [
"openrouter/deepseek/deepseek-r1:free",
"openrouter/meta-llama/llama-3.3-70b-instruct:free"
],
thinkingDefault: "low",
verboseDefault: "off",
elevatedDefault: "on",
-82
View File
@@ -1,82 +0,0 @@
---
summary: "Plan for models CLI: scan, list, aliases, fallbacks, status"
read_when:
- Adding or modifying models CLI (models list/set/scan/aliases/fallbacks)
- Changing model fallback behavior or selection UX
- Updating model scan probes (tools/images)
---
# Models CLI plan
Goal: give clear model visibility + control (configured vs available), plus scan tooling
that prefers tool-call + image-capable models and maintains ordered fallbacks.
## Command tree (draft)
- `clawdbot models list`
- default: configured models only
- flags: `--all` (full catalog), `--local`, `--provider <name>`, `--json`, `--plain`
- `clawdbot models status`
- show default model + aliases + fallbacks + allowlist
- `clawdbot models set <modelOrAlias>`
- writes `agent.model` in config
- `clawdbot models aliases list|add|remove`
- writes `agent.modelAliases`
- `clawdbot models fallbacks list|add|remove|clear`
- writes `agent.modelFallbacks`
- `clawdbot models scan`
- OpenRouter :free scan; probe tool-call + image; interactive selection
## Config changes
- Add `agent.modelFallbacks: string[]` (ordered list of provider/model IDs).
- Keep existing:
- `agent.model` (default)
- `agent.allowedModels` (list filter)
- `agent.modelAliases` (shortcut names)
## Scan behavior (models scan)
Input
- OpenRouter `/models` list (filter `:free`)
- Requires `OPENROUTER_API_KEY` (or stored OpenRouter key in auth storage)
- Optional filters: `--max-age-days`, `--min-params`, `--provider`, `--max-candidates`
- Probe controls: `--timeout`, `--concurrency`
Probes (direct pi-ai complete)
- Tool-call probe (required):
- Provide a dummy tool, verify tool call emitted.
- Image probe (preferred):
- Prompt includes 1x1 PNG; success if no "unsupported image" error.
Scoring/selection
- Prefer models passing tool + image.
- Fallback to tool-only if no tool+image pass.
- Rank by: image ok, then lower tool latency, then larger context, then params.
Interactive selection (TTY)
- Multiselect list with per-model stats:
- model id, tool ok, image ok, median latency, context, inferred params.
- Pre-select top N (default 6).
- Non-TTY: auto-select; require `--yes`/`--no-input` to apply.
Output
- Writes `agent.modelFallbacks` ordered.
- Optional `--set-default` to set `agent.model`.
## Runtime fallback
- On model failure: try `agent.modelFallbacks` in order.
- Ignore fallback entries not in `agent.allowedModels` (if allowlist set).
- Persist last successful provider/model to session entry.
- `/status` shows last used model (not just default).
## Tests
- Unit: scan selection ordering + probe classification.
- CLI: list/aliases/fallbacks add/remove + scan writes config.
- Status: shows last used model + fallbacks.
## Docs
- Update `docs/configuration.md` with `agent.modelFallbacks`.
- Keep this doc current when CLI surface or scan logic changes.
-150
View File
@@ -1,150 +0,0 @@
import type { ClawdbotConfig } from "../config/config.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
import {
buildModelAliasIndex,
modelKey,
parseModelRef,
resolveModelRefFromString,
} from "./model-selection.js";
type ModelCandidate = {
provider: string;
model: string;
};
type FallbackAttempt = {
provider: string;
model: string;
error: string;
};
function isAbortError(err: unknown): boolean {
if (!err || typeof err !== "object") return false;
const name = "name" in err ? String(err.name) : "";
if (name === "AbortError") return true;
const message =
"message" in err && typeof err.message === "string"
? err.message.toLowerCase()
: "";
return message.includes("aborted");
}
function buildAllowedModelKeys(
cfg: ClawdbotConfig | undefined,
defaultProvider: string,
): Set<string> | null {
const rawAllowlist = cfg?.agent?.allowedModels ?? [];
if (rawAllowlist.length === 0) return null;
const keys = new Set<string>();
for (const raw of rawAllowlist) {
const parsed = parseModelRef(String(raw ?? ""), defaultProvider);
if (!parsed) continue;
keys.add(modelKey(parsed.provider, parsed.model));
}
return keys.size > 0 ? keys : null;
}
function resolveFallbackCandidates(params: {
cfg: ClawdbotConfig | undefined;
provider: string;
model: string;
}): ModelCandidate[] {
const provider = params.provider.trim() || DEFAULT_PROVIDER;
const model = params.model.trim() || DEFAULT_MODEL;
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg ?? {},
defaultProvider: DEFAULT_PROVIDER,
});
const allowlist = buildAllowedModelKeys(params.cfg, DEFAULT_PROVIDER);
const seen = new Set<string>();
const candidates: ModelCandidate[] = [];
const addCandidate = (candidate: ModelCandidate, enforceAllowlist: boolean) => {
if (!candidate.provider || !candidate.model) return;
const key = modelKey(candidate.provider, candidate.model);
if (seen.has(key)) return;
if (enforceAllowlist && allowlist && !allowlist.has(key)) return;
seen.add(key);
candidates.push(candidate);
};
addCandidate({ provider, model }, false);
for (const raw of params.cfg?.agent?.modelFallbacks ?? []) {
const resolved = resolveModelRefFromString({
raw: String(raw ?? ""),
defaultProvider: DEFAULT_PROVIDER,
aliasIndex,
});
if (!resolved) continue;
addCandidate(resolved.ref, true);
}
return candidates;
}
export async function runWithModelFallback<T>(params: {
cfg: ClawdbotConfig | undefined;
provider: string;
model: string;
run: (provider: string, model: string) => Promise<T>;
onError?: (attempt: {
provider: string;
model: string;
error: unknown;
attempt: number;
total: number;
}) => void | Promise<void>;
}): Promise<{
result: T;
provider: string;
model: string;
attempts: FallbackAttempt[];
}> {
const candidates = resolveFallbackCandidates(params);
const attempts: FallbackAttempt[] = [];
let lastError: unknown;
for (let i = 0; i < candidates.length; i += 1) {
const candidate = candidates[i] as ModelCandidate;
try {
const result = await params.run(candidate.provider, candidate.model);
return {
result,
provider: candidate.provider,
model: candidate.model,
attempts,
};
} catch (err) {
if (isAbortError(err)) throw err;
lastError = err;
attempts.push({
provider: candidate.provider,
model: candidate.model,
error: err instanceof Error ? err.message : String(err),
});
await params.onError?.({
provider: candidate.provider,
model: candidate.model,
error: err,
attempt: i + 1,
total: candidates.length,
});
}
}
if (attempts.length <= 1 && lastError) throw lastError;
const summary =
attempts.length > 0
? attempts
.map(
(attempt) =>
`${attempt.provider}/${attempt.model}: ${attempt.error}`,
)
.join(" | ")
: "unknown";
throw new Error(
`All models failed (${attempts.length || candidates.length}): ${summary}`,
{ cause: lastError instanceof Error ? lastError : undefined },
);
}
-379
View File
@@ -1,379 +0,0 @@
import { Type } from "@sinclair/typebox";
import {
complete,
getEnvApiKey,
getModel,
type Context,
type Model,
type Tool,
type OpenAICompletionsOptions,
} from "@mariozechner/pi-ai";
const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
const DEFAULT_TIMEOUT_MS = 12_000;
const DEFAULT_CONCURRENCY = 3;
const BASE_IMAGE_PNG =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X3mIAAAAASUVORK5CYII=";
const TOOL_PING: Tool = {
name: "ping",
description: "Return OK.",
parameters: Type.Object({}),
};
type OpenRouterModelMeta = {
id: string;
name: string;
contextLength: number | null;
maxCompletionTokens: number | null;
supportedParametersCount: number;
modality: string | null;
inferredParamB: number | null;
createdAtMs: number | null;
};
export type ProbeResult = {
ok: boolean;
latencyMs: number | null;
error?: string;
skipped?: boolean;
};
export type ModelScanResult = {
id: string;
name: string;
provider: string;
modelRef: string;
contextLength: number | null;
maxCompletionTokens: number | null;
supportedParametersCount: number;
modality: string | null;
inferredParamB: number | null;
createdAtMs: number | null;
tool: ProbeResult;
image: ProbeResult;
};
export type OpenRouterScanOptions = {
apiKey?: string;
fetchImpl?: typeof fetch;
timeoutMs?: number;
concurrency?: number;
minParamB?: number;
maxAgeDays?: number;
providerFilter?: string;
};
type OpenAIModel = Model<"openai-completions">;
function normalizeCreatedAtMs(value: unknown): number | null {
if (typeof value !== "number" || !Number.isFinite(value)) return null;
if (value <= 0) return null;
if (value > 1e12) return Math.round(value);
return Math.round(value * 1000);
}
function inferParamBFromIdOrName(text: string): number | null {
const raw = text.toLowerCase();
const matches = raw.matchAll(
/(?:^|[^a-z0-9])[a-z]?(\d+(?:\.\d+)?)b(?:[^a-z0-9]|$)/g,
);
let best: number | null = null;
for (const match of matches) {
const numRaw = match[1];
if (!numRaw) continue;
const value = Number(numRaw);
if (!Number.isFinite(value) || value <= 0) continue;
if (best === null || value > best) best = value;
}
return best;
}
function parseModality(modality: string | null): Array<"text" | "image"> {
if (!modality) return ["text"];
const normalized = modality.toLowerCase();
const parts = normalized.split(/[^a-z]+/).filter(Boolean);
const hasImage = parts.includes("image");
return hasImage ? ["text", "image"] : ["text"];
}
async function withTimeout<T>(
timeoutMs: number,
fn: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fn(controller.signal);
} finally {
clearTimeout(timer);
}
}
async function fetchOpenRouterModels(
fetchImpl: typeof fetch,
): Promise<OpenRouterModelMeta[]> {
const res = await fetchImpl(OPENROUTER_MODELS_URL, {
headers: { Accept: "application/json" },
});
if (!res.ok) {
throw new Error(`OpenRouter /models failed: HTTP ${res.status}`);
}
const payload = (await res.json()) as { data?: unknown };
const entries = Array.isArray(payload.data) ? payload.data : [];
return entries
.map((entry) => {
if (!entry || typeof entry !== "object") return null;
const obj = entry as Record<string, unknown>;
const id = typeof obj.id === "string" ? obj.id.trim() : "";
if (!id) return null;
const name =
typeof obj.name === "string" && obj.name.trim()
? obj.name.trim()
: id;
const contextLength =
typeof obj.context_length === "number" &&
Number.isFinite(obj.context_length)
? obj.context_length
: null;
const maxCompletionTokens =
typeof obj.max_completion_tokens === "number" &&
Number.isFinite(obj.max_completion_tokens)
? obj.max_completion_tokens
: typeof obj.max_output_tokens === "number" &&
Number.isFinite(obj.max_output_tokens)
? obj.max_output_tokens
: null;
const supportedParametersCount = Array.isArray(obj.supported_parameters)
? obj.supported_parameters.length
: 0;
const modality =
typeof obj.modality === "string" && obj.modality.trim()
? obj.modality.trim()
: null;
const inferredParamB = inferParamBFromIdOrName(`${id} ${name}`);
const createdAtMs = normalizeCreatedAtMs(obj.created_at);
return {
id,
name,
contextLength,
maxCompletionTokens,
supportedParametersCount,
modality,
inferredParamB,
createdAtMs,
} satisfies OpenRouterModelMeta;
})
.filter((entry): entry is OpenRouterModelMeta => Boolean(entry));
}
async function probeTool(
model: OpenAIModel,
apiKey: string,
timeoutMs: number,
): Promise<ProbeResult> {
const context: Context = {
messages: [
{
role: "user",
content: "Call the ping tool with {} and nothing else.",
timestamp: Date.now(),
},
],
tools: [TOOL_PING],
};
const startedAt = Date.now();
try {
const message = await withTimeout(timeoutMs, (signal) =>
complete(model, context, {
apiKey,
maxTokens: 32,
temperature: 0,
toolChoice: "required",
signal,
} satisfies OpenAICompletionsOptions),
);
const hasToolCall = message.content.some(
(block) => block.type === "toolCall",
);
if (!hasToolCall) {
return {
ok: false,
latencyMs: Date.now() - startedAt,
error: "No tool call returned",
};
}
return { ok: true, latencyMs: Date.now() - startedAt };
} catch (err) {
return {
ok: false,
latencyMs: Date.now() - startedAt,
error: err instanceof Error ? err.message : String(err),
};
}
}
async function probeImage(
model: OpenAIModel,
apiKey: string,
timeoutMs: number,
): Promise<ProbeResult> {
const context: Context = {
messages: [
{
role: "user",
content: [
{ type: "text", text: "Reply with OK." },
{ type: "image", data: BASE_IMAGE_PNG, mimeType: "image/png" },
],
timestamp: Date.now(),
},
],
};
const startedAt = Date.now();
try {
await withTimeout(timeoutMs, (signal) =>
complete(model, context, {
apiKey,
maxTokens: 16,
temperature: 0,
signal,
} satisfies OpenAICompletionsOptions),
);
return { ok: true, latencyMs: Date.now() - startedAt };
} catch (err) {
return {
ok: false,
latencyMs: Date.now() - startedAt,
error: err instanceof Error ? err.message : String(err),
};
}
}
function ensureImageInput(model: OpenAIModel): OpenAIModel {
if (model.input.includes("image")) return model;
return {
...model,
input: Array.from(new Set([...model.input, "image"])),
};
}
async function mapWithConcurrency<T, R>(
items: T[],
concurrency: number,
fn: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
const limit = Math.max(1, Math.floor(concurrency));
const results: R[] = new Array(items.length);
let nextIndex = 0;
const worker = async () => {
while (true) {
const current = nextIndex;
nextIndex += 1;
if (current >= items.length) return;
results[current] = await fn(items[current] as T, current);
}
};
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, () => worker()),
);
return results;
}
export async function scanOpenRouterModels(
options: OpenRouterScanOptions = {},
): Promise<ModelScanResult[]> {
const fetchImpl = options.fetchImpl ?? fetch;
const apiKey =
options.apiKey?.trim() || getEnvApiKey("openrouter") || "";
if (!apiKey) {
throw new Error(
"Missing OpenRouter API key. Set OPENROUTER_API_KEY to run models scan.",
);
}
const timeoutMs = Math.max(
1,
Math.floor(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
);
const concurrency = Math.max(
1,
Math.floor(options.concurrency ?? DEFAULT_CONCURRENCY),
);
const minParamB = Math.max(0, Math.floor(options.minParamB ?? 0));
const maxAgeDays = Math.max(0, Math.floor(options.maxAgeDays ?? 0));
const providerFilter = options.providerFilter?.trim().toLowerCase() ?? "";
const catalog = await fetchOpenRouterModels(fetchImpl);
const now = Date.now();
const filtered = catalog.filter((entry) => {
if (!entry.id.endsWith(":free")) return false;
if (providerFilter) {
const prefix = entry.id.split("/")[0]?.toLowerCase() ?? "";
if (prefix !== providerFilter) return false;
}
if (minParamB > 0) {
const params = entry.inferredParamB ?? 0;
if (params < minParamB) return false;
}
if (maxAgeDays > 0 && entry.createdAtMs) {
const ageMs = now - entry.createdAtMs;
const ageDays = ageMs / (24 * 60 * 60 * 1000);
if (ageDays > maxAgeDays) return false;
}
return true;
});
const baseModel = getModel(
"openrouter",
"openrouter/auto",
) as OpenAIModel;
return mapWithConcurrency(filtered, concurrency, async (entry) => {
const model: OpenAIModel = {
...baseModel,
id: entry.id,
name: entry.name || entry.id,
contextWindow: entry.contextLength ?? baseModel.contextWindow,
maxTokens: entry.maxCompletionTokens ?? baseModel.maxTokens,
input: parseModality(entry.modality),
reasoning: baseModel.reasoning,
};
const toolResult = await probeTool(model, apiKey, timeoutMs);
const imageResult = toolResult.ok
? await probeImage(ensureImageInput(model), apiKey, timeoutMs)
: { ok: false, latencyMs: null, skipped: true };
return {
id: entry.id,
name: entry.name,
provider: "openrouter",
modelRef: `openrouter/${entry.id}`,
contextLength: entry.contextLength,
maxCompletionTokens: entry.maxCompletionTokens,
supportedParametersCount: entry.supportedParametersCount,
modality: entry.modality,
inferredParamB: entry.inferredParamB,
createdAtMs: entry.createdAtMs,
tool: toolResult,
image: imageResult,
} satisfies ModelScanResult;
});
}
export { OPENROUTER_MODELS_URL };
export type { OpenRouterModelMeta };
+1 -1
View File
@@ -305,7 +305,7 @@ export async function getReplyFromConfig(
(sessionEntry?.elevatedLevel as ElevatedLevel | undefined) ??
(agentCfg?.elevatedDefault as ElevatedLevel | undefined) ??
"on")
: "off";
: "on";
const resolvedBlockStreaming =
agentCfg?.blockStreamingDefault === "off" ? "off" : "on";
const resolvedBlockStreamingBreak: "text_end" | "message_end" =
+120 -151
View File
@@ -5,7 +5,6 @@ import {
queueEmbeddedPiMessage,
runEmbeddedPiAgent,
} from "../../agents/pi-embedded.js";
import { runWithModelFallback } from "../../agents/model-fallback.js";
import {
loadSessionStore,
type SessionEntry,
@@ -171,154 +170,131 @@ export async function runReplyAgent(params: {
registerAgentRunContext(runId, { sessionKey });
}
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
let fallbackProvider = followupRun.run.provider;
let fallbackModel = followupRun.run.model;
try {
const fallbackResult = await runWithModelFallback({
cfg: followupRun.run.config,
runResult = await runEmbeddedPiAgent({
sessionId: followupRun.run.sessionId,
sessionKey,
surface: sessionCtx.Surface?.trim().toLowerCase() || undefined,
sessionFile: followupRun.run.sessionFile,
workspaceDir: followupRun.run.workspaceDir,
config: followupRun.run.config,
skillsSnapshot: followupRun.run.skillsSnapshot,
prompt: commandBody,
extraSystemPrompt: followupRun.run.extraSystemPrompt,
ownerNumbers: followupRun.run.ownerNumbers,
enforceFinalTag: followupRun.run.enforceFinalTag,
provider: followupRun.run.provider,
model: followupRun.run.model,
run: (provider, model) =>
runEmbeddedPiAgent({
sessionId: followupRun.run.sessionId,
sessionKey,
surface: sessionCtx.Surface?.trim().toLowerCase() || undefined,
sessionFile: followupRun.run.sessionFile,
workspaceDir: followupRun.run.workspaceDir,
config: followupRun.run.config,
skillsSnapshot: followupRun.run.skillsSnapshot,
prompt: commandBody,
extraSystemPrompt: followupRun.run.extraSystemPrompt,
ownerNumbers: followupRun.run.ownerNumbers,
enforceFinalTag: followupRun.run.enforceFinalTag,
provider,
model,
thinkLevel: followupRun.run.thinkLevel,
verboseLevel: followupRun.run.verboseLevel,
bashElevated: followupRun.run.bashElevated,
timeoutMs: followupRun.run.timeoutMs,
runId,
blockReplyBreak: resolvedBlockStreamingBreak,
blockReplyChunking,
onPartialReply: opts?.onPartialReply
? async (payload) => {
let text = payload.text;
if (!opts?.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
const stripped = stripHeartbeatToken(text, {
mode: "message",
});
if (stripped.didStrip && !didLogHeartbeatStrip) {
didLogHeartbeatStrip = true;
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
}
if (
stripped.shouldSkip &&
(payload.mediaUrls?.length ?? 0) === 0
) {
return;
}
text = stripped.text;
}
await typing.startTypingOnText(text);
await opts.onPartialReply?.({
text,
mediaUrls: payload.mediaUrls,
});
thinkLevel: followupRun.run.thinkLevel,
verboseLevel: followupRun.run.verboseLevel,
bashElevated: followupRun.run.bashElevated,
timeoutMs: followupRun.run.timeoutMs,
runId,
blockReplyBreak: resolvedBlockStreamingBreak,
blockReplyChunking,
onPartialReply: opts?.onPartialReply
? async (payload) => {
let text = payload.text;
if (!opts?.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
const stripped = stripHeartbeatToken(text, { mode: "message" });
if (stripped.didStrip && !didLogHeartbeatStrip) {
didLogHeartbeatStrip = true;
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
}
: undefined,
onBlockReply:
blockStreamingEnabled && opts?.onBlockReply
? async (payload) => {
let text = payload.text;
if (!opts?.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
const stripped = stripHeartbeatToken(text, {
mode: "message",
});
if (stripped.didStrip && !didLogHeartbeatStrip) {
didLogHeartbeatStrip = true;
logVerbose(
"Stripped stray HEARTBEAT_OK token from reply",
);
}
const hasMedia = (payload.mediaUrls?.length ?? 0) > 0;
if (stripped.shouldSkip && !hasMedia) return;
text = stripped.text;
}
const tagResult = extractReplyToTag(
text,
sessionCtx.MessageSid,
);
const cleaned = tagResult.cleaned || undefined;
const hasMedia = (payload.mediaUrls?.length ?? 0) > 0;
if (!cleaned && !hasMedia) return;
if (cleaned?.trim() === SILENT_REPLY_TOKEN && !hasMedia)
return;
const blockPayload: ReplyPayload = {
text: cleaned,
mediaUrls: payload.mediaUrls,
mediaUrl: payload.mediaUrls?.[0],
replyToId: tagResult.replyToId,
};
const payloadKey = buildPayloadKey(blockPayload);
if (
streamedPayloadKeys.has(payloadKey) ||
pendingStreamedPayloadKeys.has(payloadKey)
) {
return;
}
pendingStreamedPayloadKeys.add(payloadKey);
const task = (async () => {
await typing.startTypingOnText(cleaned);
await opts.onBlockReply?.(blockPayload);
})()
.then(() => {
streamedPayloadKeys.add(payloadKey);
didStreamBlockReply = true;
})
.catch((err) => {
logVerbose(
`block reply delivery failed: ${String(err)}`,
);
})
.finally(() => {
pendingStreamedPayloadKeys.delete(payloadKey);
});
pendingBlockTasks.add(task);
void task.finally(() => pendingBlockTasks.delete(task));
}
: undefined,
shouldEmitToolResult,
onToolResult: opts?.onToolResult
? async (payload) => {
let text = payload.text;
if (!opts?.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
const stripped = stripHeartbeatToken(text, {
mode: "message",
});
if (stripped.didStrip && !didLogHeartbeatStrip) {
didLogHeartbeatStrip = true;
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
}
if (
stripped.shouldSkip &&
(payload.mediaUrls?.length ?? 0) === 0
) {
return;
}
text = stripped.text;
}
await typing.startTypingOnText(text);
await opts.onToolResult?.({
text,
mediaUrls: payload.mediaUrls,
});
if (
stripped.shouldSkip &&
(payload.mediaUrls?.length ?? 0) === 0
) {
return;
}
: undefined,
}),
text = stripped.text;
}
await typing.startTypingOnText(text);
await opts.onPartialReply?.({
text,
mediaUrls: payload.mediaUrls,
});
}
: undefined,
onBlockReply:
blockStreamingEnabled && opts?.onBlockReply
? async (payload) => {
let text = payload.text;
if (!opts?.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
const stripped = stripHeartbeatToken(text, {
mode: "message",
});
if (stripped.didStrip && !didLogHeartbeatStrip) {
didLogHeartbeatStrip = true;
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
}
const hasMedia = (payload.mediaUrls?.length ?? 0) > 0;
if (stripped.shouldSkip && !hasMedia) return;
text = stripped.text;
}
const tagResult = extractReplyToTag(
text,
sessionCtx.MessageSid,
);
const cleaned = tagResult.cleaned || undefined;
const hasMedia = (payload.mediaUrls?.length ?? 0) > 0;
if (!cleaned && !hasMedia) return;
if (cleaned?.trim() === SILENT_REPLY_TOKEN && !hasMedia) return;
const blockPayload: ReplyPayload = {
text: cleaned,
mediaUrls: payload.mediaUrls,
mediaUrl: payload.mediaUrls?.[0],
replyToId: tagResult.replyToId,
};
const payloadKey = buildPayloadKey(blockPayload);
if (
streamedPayloadKeys.has(payloadKey) ||
pendingStreamedPayloadKeys.has(payloadKey)
) {
return;
}
pendingStreamedPayloadKeys.add(payloadKey);
const task = (async () => {
await typing.startTypingOnText(cleaned);
await opts.onBlockReply?.(blockPayload);
})()
.then(() => {
streamedPayloadKeys.add(payloadKey);
didStreamBlockReply = true;
})
.catch((err) => {
logVerbose(`block reply delivery failed: ${String(err)}`);
})
.finally(() => {
pendingStreamedPayloadKeys.delete(payloadKey);
});
pendingBlockTasks.add(task);
void task.finally(() => pendingBlockTasks.delete(task));
}
: undefined,
shouldEmitToolResult,
onToolResult: opts?.onToolResult
? async (payload) => {
let text = payload.text;
if (!opts?.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
const stripped = stripHeartbeatToken(text, { mode: "message" });
if (stripped.didStrip && !didLogHeartbeatStrip) {
didLogHeartbeatStrip = true;
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
}
if (
stripped.shouldSkip &&
(payload.mediaUrls?.length ?? 0) === 0
) {
return;
}
text = stripped.text;
}
await typing.startTypingOnText(text);
await opts.onToolResult?.({ text, mediaUrls: payload.mediaUrls });
}
: undefined,
});
runResult = fallbackResult.result;
fallbackProvider = fallbackResult.provider;
fallbackModel = fallbackResult.model;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const isContextOverflow =
@@ -412,12 +388,7 @@ export async function runReplyAgent(params: {
if (sessionStore && sessionKey) {
const usage = runResult.meta.agentMeta?.usage;
const modelUsed =
runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
const providerUsed =
runResult.meta.agentMeta?.provider ??
fallbackProvider ??
followupRun.run.provider;
const modelUsed = runResult.meta.agentMeta?.model ?? defaultModel;
const contextTokensUsed =
agentCfgContextTokens ??
lookupContextTokens(modelUsed) ??
@@ -437,7 +408,6 @@ export async function runReplyAgent(params: {
outputTokens: output,
totalTokens:
promptTokens > 0 ? promptTokens : (usage.total ?? input),
modelProvider: providerUsed,
model: modelUsed,
contextTokens: contextTokensUsed ?? entry.contextTokens,
updatedAt: Date.now(),
@@ -452,7 +422,6 @@ export async function runReplyAgent(params: {
if (entry) {
sessionStore[sessionKey] = {
...entry,
modelProvider: providerUsed ?? entry.modelProvider,
model: modelUsed ?? entry.model,
contextTokens: contextTokensUsed ?? entry.contextTokens,
};
+19 -34
View File
@@ -1,7 +1,6 @@
import crypto from "node:crypto";
import { lookupContextTokens } from "../../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
import { runWithModelFallback } from "../../agents/model-fallback.js";
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
import { logVerbose } from "../../globals.js";
@@ -62,39 +61,28 @@ export function createFollowupRunner(params: {
registerAgentRunContext(runId, { sessionKey: queued.run.sessionKey });
}
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
let fallbackProvider = queued.run.provider;
let fallbackModel = queued.run.model;
try {
const fallbackResult = await runWithModelFallback({
cfg: queued.run.config,
runResult = await runEmbeddedPiAgent({
sessionId: queued.run.sessionId,
sessionKey: queued.run.sessionKey,
surface: queued.run.surface,
sessionFile: queued.run.sessionFile,
workspaceDir: queued.run.workspaceDir,
config: queued.run.config,
skillsSnapshot: queued.run.skillsSnapshot,
prompt: queued.prompt,
extraSystemPrompt: queued.run.extraSystemPrompt,
ownerNumbers: queued.run.ownerNumbers,
enforceFinalTag: queued.run.enforceFinalTag,
provider: queued.run.provider,
model: queued.run.model,
run: (provider, model) =>
runEmbeddedPiAgent({
sessionId: queued.run.sessionId,
sessionKey: queued.run.sessionKey,
surface: queued.run.surface,
sessionFile: queued.run.sessionFile,
workspaceDir: queued.run.workspaceDir,
config: queued.run.config,
skillsSnapshot: queued.run.skillsSnapshot,
prompt: queued.prompt,
extraSystemPrompt: queued.run.extraSystemPrompt,
ownerNumbers: queued.run.ownerNumbers,
enforceFinalTag: queued.run.enforceFinalTag,
provider,
model,
thinkLevel: queued.run.thinkLevel,
verboseLevel: queued.run.verboseLevel,
bashElevated: queued.run.bashElevated,
timeoutMs: queued.run.timeoutMs,
runId,
blockReplyBreak: queued.run.blockReplyBreak,
}),
thinkLevel: queued.run.thinkLevel,
verboseLevel: queued.run.verboseLevel,
bashElevated: queued.run.bashElevated,
timeoutMs: queued.run.timeoutMs,
runId,
blockReplyBreak: queued.run.blockReplyBreak,
});
runResult = fallbackResult.result;
fallbackProvider = fallbackResult.provider;
fallbackModel = fallbackResult.model;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
defaultRuntime.error?.(`Followup agent failed before reply: ${message}`);
@@ -133,8 +121,7 @@ export function createFollowupRunner(params: {
if (sessionStore && sessionKey) {
const usage = runResult.meta.agentMeta?.usage;
const modelUsed =
runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
const modelUsed = runResult.meta.agentMeta?.model ?? defaultModel;
const contextTokensUsed =
agentCfgContextTokens ??
lookupContextTokens(modelUsed) ??
@@ -154,7 +141,6 @@ export function createFollowupRunner(params: {
outputTokens: output,
totalTokens:
promptTokens > 0 ? promptTokens : (usage.total ?? input),
modelProvider: fallbackProvider ?? entry.modelProvider,
model: modelUsed,
contextTokens: contextTokensUsed ?? entry.contextTokens,
updatedAt: Date.now(),
@@ -168,7 +154,6 @@ export function createFollowupRunner(params: {
if (entry) {
sessionStore[sessionKey] = {
...entry,
modelProvider: fallbackProvider ?? entry.modelProvider,
model: modelUsed ?? entry.model,
contextTokens: contextTokensUsed ?? entry.contextTokens,
};
+1 -2
View File
@@ -133,7 +133,6 @@ export function buildStatusMessage(args: StatusArgs): string {
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const provider = entry?.modelProvider ?? resolved.provider ?? DEFAULT_PROVIDER;
let model = entry?.model ?? resolved.model ?? DEFAULT_MODEL;
let contextTokens =
entry?.contextTokens ??
@@ -205,7 +204,7 @@ export function buildStatusMessage(args: StatusArgs): string {
const optionsLine = `Options: thinking=${thinkLevel} | verbose=${verboseLevel} | elevated=${elevatedLevel} (set with /think <level>, /verbose on|off, /elevated on|off, /model <id>)`;
const modelLabel = model ? `${provider}/${model}` : "unknown";
const modelLabel = model ? `${resolved.provider}/${model}` : "unknown";
const agentLine = `Agent: embedded pi • ${modelLabel}`;
-198
View File
@@ -1,198 +0,0 @@
import type { Command } from "commander";
import {
modelsAliasesAddCommand,
modelsAliasesListCommand,
modelsAliasesRemoveCommand,
modelsFallbacksAddCommand,
modelsFallbacksClearCommand,
modelsFallbacksListCommand,
modelsFallbacksRemoveCommand,
modelsListCommand,
modelsScanCommand,
modelsSetCommand,
modelsStatusCommand,
} from "../commands/models.js";
import { defaultRuntime } from "../runtime.js";
export function registerModelsCli(program: Command) {
const models = program
.command("models")
.description("Model discovery, scanning, and configuration");
models
.command("list")
.description("List models (configured by default)")
.option("--all", "Show full model catalog", false)
.option("--local", "Filter to local models", false)
.option("--provider <name>", "Filter by provider")
.option("--json", "Output JSON", false)
.option("--plain", "Plain line output", false)
.action(async (opts) => {
try {
await modelsListCommand(opts, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
models
.command("status")
.description("Show configured model state")
.option("--json", "Output JSON", false)
.option("--plain", "Plain output", false)
.action(async (opts) => {
try {
await modelsStatusCommand(opts, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
models
.command("set")
.description("Set the default model")
.argument("<model>", "Model id or alias")
.action(async (model: string) => {
try {
await modelsSetCommand(model, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
const aliases = models
.command("aliases")
.description("Manage model aliases");
aliases
.command("list")
.description("List model aliases")
.option("--json", "Output JSON", false)
.option("--plain", "Plain output", false)
.action(async (opts) => {
try {
await modelsAliasesListCommand(opts, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
aliases
.command("add")
.description("Add or update a model alias")
.argument("<alias>", "Alias name")
.argument("<model>", "Model id or alias")
.action(async (alias: string, model: string) => {
try {
await modelsAliasesAddCommand(alias, model, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
aliases
.command("remove")
.description("Remove a model alias")
.argument("<alias>", "Alias name")
.action(async (alias: string) => {
try {
await modelsAliasesRemoveCommand(alias, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
const fallbacks = models
.command("fallbacks")
.description("Manage model fallback list");
fallbacks
.command("list")
.description("List fallback models")
.option("--json", "Output JSON", false)
.option("--plain", "Plain output", false)
.action(async (opts) => {
try {
await modelsFallbacksListCommand(opts, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
fallbacks
.command("add")
.description("Add a fallback model")
.argument("<model>", "Model id or alias")
.action(async (model: string) => {
try {
await modelsFallbacksAddCommand(model, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
fallbacks
.command("remove")
.description("Remove a fallback model")
.argument("<model>", "Model id or alias")
.action(async (model: string) => {
try {
await modelsFallbacksRemoveCommand(model, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
fallbacks
.command("clear")
.description("Clear all fallback models")
.action(async () => {
try {
await modelsFallbacksClearCommand(defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
models
.command("scan")
.description("Scan OpenRouter free models for tools + images")
.option("--min-params <b>", "Minimum parameter size (billions)")
.option("--max-age-days <days>", "Skip models older than N days")
.option("--provider <name>", "Filter by provider prefix")
.option("--max-candidates <n>", "Max fallback candidates", "6")
.option("--timeout <ms>", "Per-probe timeout in ms")
.option("--concurrency <n>", "Probe concurrency")
.option("--yes", "Accept defaults without prompting", false)
.option("--no-input", "Disable prompts (use defaults)")
.option("--set-default", "Set agent.model to the first selection", false)
.option("--json", "Output JSON", false)
.action(async (opts) => {
try {
await modelsScanCommand(opts, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
models.action(async () => {
try {
await modelsStatusCommand({}, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
}
-2
View File
@@ -22,7 +22,6 @@ import { createDefaultDeps } from "./deps.js";
import { registerDnsCli } from "./dns-cli.js";
import { registerGatewayCli } from "./gateway-cli.js";
import { registerHooksCli } from "./hooks-cli.js";
import { registerModelsCli } from "./models-cli.js";
import { registerNodesCli } from "./nodes-cli.js";
import { forceFreePort } from "./ports.js";
import { registerTuiCli } from "./tui-cli.js";
@@ -400,7 +399,6 @@ Examples:
registerCanvasCli(program);
registerGatewayCli(program);
registerModelsCli(program);
registerNodesCli(program);
registerTuiCli(program);
registerCronCli(program);
+23 -39
View File
@@ -12,7 +12,6 @@ import {
resolveConfiguredModelRef,
resolveThinkingDefault,
} from "../agents/model-selection.js";
import { runWithModelFallback } from "../agents/model-fallback.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
import {
@@ -365,8 +364,6 @@ export async function agentCommand(
});
let result: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
let fallbackProvider = provider;
let fallbackModel = model;
try {
const surface =
opts.surface?.trim().toLowerCase() ||
@@ -375,41 +372,32 @@ export async function agentCommand(
if (!raw) return undefined;
return raw === "imsg" ? "imessage" : raw;
})();
const fallbackResult = await runWithModelFallback({
cfg,
result = await runEmbeddedPiAgent({
sessionId,
sessionKey,
surface,
sessionFile,
workspaceDir,
config: cfg,
skillsSnapshot,
prompt: body,
provider,
model,
run: (providerOverride, modelOverride) =>
runEmbeddedPiAgent({
sessionId,
sessionKey,
surface,
sessionFile,
workspaceDir,
config: cfg,
skillsSnapshot,
prompt: body,
provider: providerOverride,
model: modelOverride,
thinkLevel: resolvedThinkLevel,
verboseLevel: resolvedVerboseLevel,
timeoutMs,
thinkLevel: resolvedThinkLevel,
verboseLevel: resolvedVerboseLevel,
timeoutMs,
runId,
lane: opts.lane,
abortSignal: opts.abortSignal,
extraSystemPrompt: opts.extraSystemPrompt,
onAgentEvent: (evt) => {
emitAgentEvent({
runId,
lane: opts.lane,
abortSignal: opts.abortSignal,
extraSystemPrompt: opts.extraSystemPrompt,
onAgentEvent: (evt) => {
emitAgentEvent({
runId,
stream: evt.stream,
data: evt.data,
});
},
}),
stream: evt.stream,
data: evt.data,
});
},
});
result = fallbackResult.result;
fallbackProvider = fallbackResult.provider;
fallbackModel = fallbackResult.model;
emitAgentEvent({
runId,
stream: "job",
@@ -443,10 +431,7 @@ export async function agentCommand(
// Update token+model fields in the session store.
if (sessionStore && sessionKey) {
const usage = result.meta.agentMeta?.usage;
const modelUsed =
result.meta.agentMeta?.model ?? fallbackModel ?? model;
const providerUsed =
result.meta.agentMeta?.provider ?? fallbackProvider ?? provider;
const modelUsed = result.meta.agentMeta?.model ?? model;
const contextTokens =
agentCfg?.contextTokens ??
lookupContextTokens(modelUsed) ??
@@ -460,7 +445,6 @@ export async function agentCommand(
...entry,
sessionId,
updatedAt: Date.now(),
modelProvider: providerUsed,
model: modelUsed,
contextTokens,
};
-14
View File
@@ -1,14 +0,0 @@
export { modelsListCommand, modelsStatusCommand } from "./models/list.js";
export {
modelsAliasesAddCommand,
modelsAliasesListCommand,
modelsAliasesRemoveCommand,
} from "./models/aliases.js";
export {
modelsFallbacksAddCommand,
modelsFallbacksClearCommand,
modelsFallbacksListCommand,
modelsFallbacksRemoveCommand,
} from "./models/fallbacks.js";
export { modelsScanCommand } from "./models/scan.js";
export { modelsSetCommand } from "./models/set.js";
-89
View File
@@ -1,89 +0,0 @@
import {
CONFIG_PATH_CLAWDBOT,
loadConfig,
} from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js";
import {
ensureFlagCompatibility,
normalizeAlias,
resolveModelTarget,
updateConfig,
} from "./shared.js";
export async function modelsAliasesListCommand(
opts: { json?: boolean; plain?: boolean },
runtime: RuntimeEnv,
) {
ensureFlagCompatibility(opts);
const cfg = loadConfig();
const aliases = cfg.agent?.modelAliases ?? {};
if (opts.json) {
runtime.log(JSON.stringify({ aliases }, null, 2));
return;
}
if (opts.plain) {
for (const [alias, target] of Object.entries(aliases)) {
runtime.log(`${alias} ${target}`);
}
return;
}
runtime.log(`Aliases (${Object.keys(aliases).length}):`);
if (Object.keys(aliases).length === 0) {
runtime.log("- none");
return;
}
for (const [alias, target] of Object.entries(aliases)) {
runtime.log(`- ${alias} -> ${target}`);
}
}
export async function modelsAliasesAddCommand(
aliasRaw: string,
modelRaw: string,
runtime: RuntimeEnv,
) {
const alias = normalizeAlias(aliasRaw);
const updated = await updateConfig((cfg) => {
const resolved = resolveModelTarget({ raw: modelRaw, cfg });
const nextAliases = { ...(cfg.agent?.modelAliases ?? {}) };
nextAliases[alias] = `${resolved.provider}/${resolved.model}`;
return {
...cfg,
agent: {
...cfg.agent,
modelAliases: nextAliases,
},
};
});
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Alias ${alias} -> ${updated.agent?.modelAliases?.[alias]}`);
}
export async function modelsAliasesRemoveCommand(
aliasRaw: string,
runtime: RuntimeEnv,
) {
const alias = normalizeAlias(aliasRaw);
const updated = await updateConfig((cfg) => {
const nextAliases = { ...(cfg.agent?.modelAliases ?? {}) };
if (!nextAliases[alias]) {
throw new Error(`Alias not found: ${alias}`);
}
delete nextAliases[alias];
return {
...cfg,
agent: {
...cfg.agent,
modelAliases: nextAliases,
},
};
});
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
if (!updated.agent?.modelAliases || Object.keys(updated.agent.modelAliases).length === 0) {
runtime.log("No aliases configured.");
}
}
-134
View File
@@ -1,134 +0,0 @@
import {
CONFIG_PATH_CLAWDBOT,
loadConfig,
} from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js";
import {
buildModelAliasIndex,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import {
DEFAULT_PROVIDER,
ensureFlagCompatibility,
modelKey,
resolveModelTarget,
updateConfig,
} from "./shared.js";
export async function modelsFallbacksListCommand(
opts: { json?: boolean; plain?: boolean },
runtime: RuntimeEnv,
) {
ensureFlagCompatibility(opts);
const cfg = loadConfig();
const fallbacks = cfg.agent?.modelFallbacks ?? [];
if (opts.json) {
runtime.log(JSON.stringify({ fallbacks }, null, 2));
return;
}
if (opts.plain) {
for (const entry of fallbacks) runtime.log(entry);
return;
}
runtime.log(`Fallbacks (${fallbacks.length}):`);
if (fallbacks.length === 0) {
runtime.log("- none");
return;
}
for (const entry of fallbacks) runtime.log(`- ${entry}`);
}
export async function modelsFallbacksAddCommand(
modelRaw: string,
runtime: RuntimeEnv,
) {
const updated = await updateConfig((cfg) => {
const resolved = resolveModelTarget({ raw: modelRaw, cfg });
const targetKey = modelKey(resolved.provider, resolved.model);
const aliasIndex = buildModelAliasIndex({
cfg,
defaultProvider: DEFAULT_PROVIDER,
});
const existing = cfg.agent?.modelFallbacks ?? [];
const existingKeys = existing
.map((entry) =>
resolveModelRefFromString({
raw: String(entry ?? ""),
defaultProvider: DEFAULT_PROVIDER,
aliasIndex,
}),
)
.filter(Boolean)
.map((entry) => modelKey(entry!.ref.provider, entry!.ref.model));
if (existingKeys.includes(targetKey)) return cfg;
return {
...cfg,
agent: {
...cfg.agent,
modelFallbacks: [...existing, targetKey],
},
};
});
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Fallbacks: ${(updated.agent?.modelFallbacks ?? []).join(", ")}`);
}
export async function modelsFallbacksRemoveCommand(
modelRaw: string,
runtime: RuntimeEnv,
) {
const updated = await updateConfig((cfg) => {
const resolved = resolveModelTarget({ raw: modelRaw, cfg });
const targetKey = modelKey(resolved.provider, resolved.model);
const aliasIndex = buildModelAliasIndex({
cfg,
defaultProvider: DEFAULT_PROVIDER,
});
const existing = cfg.agent?.modelFallbacks ?? [];
const filtered = existing.filter((entry) => {
const resolvedEntry = resolveModelRefFromString({
raw: String(entry ?? ""),
defaultProvider: DEFAULT_PROVIDER,
aliasIndex,
});
if (!resolvedEntry) return true;
return (
modelKey(resolvedEntry.ref.provider, resolvedEntry.ref.model) !==
targetKey
);
});
if (filtered.length === existing.length) {
throw new Error(`Fallback not found: ${targetKey}`);
}
return {
...cfg,
agent: {
...cfg.agent,
modelFallbacks: filtered,
},
};
});
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Fallbacks: ${(updated.agent?.modelFallbacks ?? []).join(", ")}`);
}
export async function modelsFallbacksClearCommand(runtime: RuntimeEnv) {
await updateConfig((cfg) => ({
...cfg,
agent: {
...cfg.agent,
modelFallbacks: [],
},
}));
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log("Fallback list cleared.");
}
-419
View File
@@ -1,419 +0,0 @@
import chalk from "chalk";
import {
discoverAuthStorage,
discoverModels,
} from "@mariozechner/pi-coding-agent";
import { getEnvApiKey, type Api, type Model } from "@mariozechner/pi-ai";
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
import { ensureClawdbotModelsJson } from "../../agents/models-config.js";
import {
buildModelAliasIndex,
parseModelRef,
resolveModelRefFromString,
resolveConfiguredModelRef,
} from "../../agents/model-selection.js";
import {
CONFIG_PATH_CLAWDBOT,
loadConfig,
type ClawdbotConfig,
} from "../../config/config.js";
import { info } from "../../globals.js";
import type { RuntimeEnv } from "../../runtime.js";
import {
DEFAULT_MODEL,
DEFAULT_PROVIDER,
ensureFlagCompatibility,
formatTokenK,
modelKey,
} from "./shared.js";
const MODEL_PAD = 42;
const INPUT_PAD = 10;
const CTX_PAD = 8;
const LOCAL_PAD = 5;
const AUTH_PAD = 5;
const isRich = (opts?: { json?: boolean; plain?: boolean }) =>
Boolean(process.stdout.isTTY && chalk.level > 0 && !opts?.json && !opts?.plain);
const pad = (value: string, size: number) => value.padEnd(size);
const truncate = (value: string, max: number) => {
if (value.length <= max) return value;
if (max <= 3) return value.slice(0, max);
return `${value.slice(0, max - 3)}...`;
};
type ConfiguredEntry = {
key: string;
ref: { provider: string; model: string };
tags: Set<string>;
aliases: string[];
};
type ModelRow = {
key: string;
name: string;
input: string;
contextWindow: number | null;
local: boolean | null;
available: boolean | null;
tags: string[];
missing: boolean;
};
const isLocalBaseUrl = (baseUrl: string) => {
try {
const url = new URL(baseUrl);
const host = url.hostname.toLowerCase();
return (
host === "localhost" ||
host === "127.0.0.1" ||
host === "0.0.0.0" ||
host === "::1" ||
host.endsWith(".local")
);
} catch {
return false;
}
};
const resolveConfiguredEntries = (cfg: ClawdbotConfig) => {
const resolvedDefault = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const aliasIndex = buildModelAliasIndex({
cfg,
defaultProvider: DEFAULT_PROVIDER,
});
const order: string[] = [];
const tagsByKey = new Map<string, Set<string>>();
const aliasesByKey = new Map<string, string[]>();
for (const [key, aliases] of aliasIndex.byKey.entries()) {
aliasesByKey.set(key, aliases);
}
const addEntry = (ref: { provider: string; model: string }, tag: string) => {
const key = modelKey(ref.provider, ref.model);
if (!tagsByKey.has(key)) {
tagsByKey.set(key, new Set());
order.push(key);
}
tagsByKey.get(key)?.add(tag);
};
addEntry(resolvedDefault, "default");
(cfg.agent?.modelFallbacks ?? []).forEach((raw, idx) => {
const resolved = resolveModelRefFromString({
raw: String(raw ?? ""),
defaultProvider: DEFAULT_PROVIDER,
aliasIndex,
});
if (!resolved) return;
addEntry(resolved.ref, `fallback#${idx + 1}`);
});
(cfg.agent?.allowedModels ?? []).forEach((raw) => {
const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER);
if (!parsed) return;
addEntry(parsed, "allowed");
});
for (const targetRaw of Object.values(cfg.agent?.modelAliases ?? {})) {
const resolved = resolveModelRefFromString({
raw: String(targetRaw ?? ""),
defaultProvider: DEFAULT_PROVIDER,
aliasIndex,
});
if (!resolved) continue;
addEntry(resolved.ref, "alias");
}
const entries: ConfiguredEntry[] = order.map((key) => {
const slash = key.indexOf("/");
const provider = slash === -1 ? key : key.slice(0, slash);
const model = slash === -1 ? "" : key.slice(slash + 1);
return {
key,
ref: { provider, model },
tags: tagsByKey.get(key) ?? new Set(),
aliases: aliasesByKey.get(key) ?? [],
} satisfies ConfiguredEntry;
});
return { entries };
};
async function loadModelRegistry(cfg: ClawdbotConfig) {
await ensureClawdbotModelsJson(cfg);
const agentDir = resolveClawdbotAgentDir();
const authStorage = discoverAuthStorage(agentDir);
const registry = discoverModels(authStorage, agentDir);
const models = registry.getAll() as Model<Api>[];
const availableModels = registry.getAvailable() as Model<Api>[];
const availableKeys = new Set(
availableModels.map((model) => modelKey(model.provider, model.id)),
);
return { registry, models, availableKeys };
}
function toModelRow(params: {
model?: Model<Api>;
key: string;
tags: string[];
aliases?: string[];
availableKeys?: Set<string>;
}): ModelRow {
const { model, key, tags, aliases = [], availableKeys } = params;
if (!model) {
return {
key,
name: key,
input: "-",
contextWindow: null,
local: null,
available: null,
tags: [...tags, "missing"],
missing: true,
};
}
const input = model.input.join("+") || "text";
const local = isLocalBaseUrl(model.baseUrl);
const envKey = getEnvApiKey(model.provider);
const available =
availableKeys?.has(modelKey(model.provider, model.id)) || Boolean(envKey);
const aliasTags = aliases.length > 0 ? [`alias:${aliases.join(",")}`] : [];
const mergedTags = new Set(tags);
if (aliasTags.length > 0) {
for (const tag of mergedTags) {
if (tag === "alias" || tag.startsWith("alias:")) mergedTags.delete(tag);
}
for (const tag of aliasTags) mergedTags.add(tag);
}
return {
key,
name: model.name || model.id,
input,
contextWindow: model.contextWindow ?? null,
local,
available,
tags: Array.from(mergedTags),
missing: false,
};
}
function printModelTable(
rows: ModelRow[],
runtime: RuntimeEnv,
opts: { json?: boolean; plain?: boolean } = {},
) {
if (opts.json) {
runtime.log(
JSON.stringify(
{
count: rows.length,
models: rows,
},
null,
2,
),
);
return;
}
if (opts.plain) {
for (const row of rows) runtime.log(row.key);
return;
}
const rich = isRich(opts);
const header = [
pad("Model", MODEL_PAD),
pad("Input", INPUT_PAD),
pad("Ctx", CTX_PAD),
pad("Local", LOCAL_PAD),
pad("Auth", AUTH_PAD),
"Tags",
].join(" ");
runtime.log(rich ? chalk.bold(header) : header);
for (const row of rows) {
const keyLabel = pad(truncate(row.key, MODEL_PAD), MODEL_PAD);
const inputLabel = pad(row.input || "-", INPUT_PAD);
const ctxLabel = pad(formatTokenK(row.contextWindow), CTX_PAD);
const localLabel = pad(
row.local === null ? "-" : row.local ? "yes" : "no",
LOCAL_PAD,
);
const authLabel = pad(
row.available === null ? "-" : row.available ? "yes" : "no",
AUTH_PAD,
);
const tagsLabel = row.tags.length > 0 ? row.tags.join(",") : "";
const line = [
rich ? chalk.cyan(keyLabel) : keyLabel,
inputLabel,
ctxLabel,
localLabel,
authLabel,
rich ? chalk.gray(tagsLabel) : tagsLabel,
].join(" ");
runtime.log(line);
}
}
export async function modelsListCommand(
opts: {
all?: boolean;
local?: boolean;
provider?: string;
json?: boolean;
plain?: boolean;
},
runtime: RuntimeEnv,
) {
ensureFlagCompatibility(opts);
const cfg = loadConfig();
const providerFilter = opts.provider?.trim().toLowerCase();
let models: Model<Api>[] = [];
let availableKeys: Set<string> | undefined;
try {
const loaded = await loadModelRegistry(cfg);
models = loaded.models;
availableKeys = loaded.availableKeys;
} catch (err) {
runtime.error(`Model registry unavailable: ${String(err)}`);
}
const modelByKey = new Map(
models.map((model) => [modelKey(model.provider, model.id), model]),
);
const { entries } = resolveConfiguredEntries(cfg);
const configuredByKey = new Map(entries.map((entry) => [entry.key, entry]));
const rows: ModelRow[] = [];
if (opts.all) {
const sorted = [...models].sort((a, b) => {
const p = a.provider.localeCompare(b.provider);
if (p !== 0) return p;
return a.id.localeCompare(b.id);
});
for (const model of sorted) {
if (providerFilter && model.provider.toLowerCase() !== providerFilter) {
continue;
}
if (opts.local && !isLocalBaseUrl(model.baseUrl)) continue;
const key = modelKey(model.provider, model.id);
const configured = configuredByKey.get(key);
rows.push(
toModelRow({
model,
key,
tags: configured ? Array.from(configured.tags) : [],
aliases: configured?.aliases ?? [],
availableKeys,
}),
);
}
} else {
for (const entry of entries) {
if (
providerFilter &&
entry.ref.provider.toLowerCase() !== providerFilter
) {
continue;
}
const model = modelByKey.get(entry.key);
if (opts.local && model && !isLocalBaseUrl(model.baseUrl)) continue;
if (opts.local && !model) continue;
rows.push(
toModelRow({
model,
key: entry.key,
tags: Array.from(entry.tags),
aliases: entry.aliases,
availableKeys,
}),
);
}
}
if (rows.length === 0) {
runtime.log("No models found.");
return;
}
printModelTable(rows, runtime, opts);
}
export async function modelsStatusCommand(
opts: { json?: boolean; plain?: boolean },
runtime: RuntimeEnv,
) {
ensureFlagCompatibility(opts);
const cfg = loadConfig();
const resolved = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const rawModel = cfg.agent?.model?.trim() ?? "";
const defaultLabel = rawModel || `${resolved.provider}/${resolved.model}`;
const fallbacks = cfg.agent?.modelFallbacks ?? [];
const aliases = cfg.agent?.modelAliases ?? {};
const allowed = cfg.agent?.allowedModels ?? [];
if (opts.json) {
runtime.log(
JSON.stringify(
{
configPath: CONFIG_PATH_CLAWDBOT,
defaultModel: defaultLabel,
resolvedDefault: `${resolved.provider}/${resolved.model}`,
fallbacks,
aliases,
allowed,
},
null,
2,
),
);
return;
}
if (opts.plain) {
runtime.log(defaultLabel);
return;
}
runtime.log(info(`Config: ${CONFIG_PATH_CLAWDBOT}`));
runtime.log(`Default: ${defaultLabel}`);
runtime.log(
`Fallbacks (${fallbacks.length || 0}): ${fallbacks.join(", ") || "-"}`,
);
runtime.log(
`Aliases (${Object.keys(aliases).length || 0}): ${
Object.keys(aliases).length
? Object.entries(aliases)
.map(([alias, target]) => `${alias} -> ${target}`)
.join(", ")
: "-"
}`,
);
runtime.log(
`Allowed (${allowed.length || 0}): ${allowed.length ? allowed.join(", ") : "all"}`,
);
}
-267
View File
@@ -1,267 +0,0 @@
import { cancel, isCancel, multiselect } from "@clack/prompts";
import { discoverAuthStorage } from "@mariozechner/pi-coding-agent";
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
import {
scanOpenRouterModels,
type ModelScanResult,
} from "../../agents/model-scan.js";
import { warn } from "../../globals.js";
import type { RuntimeEnv } from "../../runtime.js";
import {
buildAllowlistSet,
formatMs,
formatTokenK,
updateConfig,
} from "./shared.js";
import { CONFIG_PATH_CLAWDBOT } from "../../config/config.js";
const MODEL_PAD = 42;
const CTX_PAD = 8;
const pad = (value: string, size: number) => value.padEnd(size);
const truncate = (value: string, max: number) => {
if (value.length <= max) return value;
if (max <= 3) return value.slice(0, max);
return `${value.slice(0, max - 3)}...`;
};
function sortScanResults(results: ModelScanResult[]): ModelScanResult[] {
return results.slice().sort((a, b) => {
const aImage = a.image.ok ? 1 : 0;
const bImage = b.image.ok ? 1 : 0;
if (aImage !== bImage) return bImage - aImage;
const aToolLatency = a.tool.latencyMs ?? Number.POSITIVE_INFINITY;
const bToolLatency = b.tool.latencyMs ?? Number.POSITIVE_INFINITY;
if (aToolLatency !== bToolLatency) return aToolLatency - bToolLatency;
const aCtx = a.contextLength ?? 0;
const bCtx = b.contextLength ?? 0;
if (aCtx !== bCtx) return bCtx - aCtx;
const aParams = a.inferredParamB ?? 0;
const bParams = b.inferredParamB ?? 0;
if (aParams !== bParams) return bParams - aParams;
return a.modelRef.localeCompare(b.modelRef);
});
}
function buildScanHint(result: ModelScanResult): string {
const toolLabel = result.tool.ok
? `tool ${formatMs(result.tool.latencyMs)}`
: "tool fail";
const imageLabel = result.image.skipped
? "img skip"
: result.image.ok
? `img ${formatMs(result.image.latencyMs)}`
: "img fail";
const ctxLabel = result.contextLength
? `ctx ${formatTokenK(result.contextLength)}`
: "ctx ?";
const paramLabel = result.inferredParamB ? `${result.inferredParamB}b` : null;
return [toolLabel, imageLabel, ctxLabel, paramLabel]
.filter(Boolean)
.join(" | ");
}
function printScanSummary(results: ModelScanResult[], runtime: RuntimeEnv) {
const toolOk = results.filter((r) => r.tool.ok);
const imageOk = results.filter((r) => r.image.ok);
const toolImageOk = results.filter((r) => r.tool.ok && r.image.ok);
runtime.log(
`Scan results: tested ${results.length}, tool ok ${toolOk.length}, image ok ${imageOk.length}, tool+image ok ${toolImageOk.length}`,
);
}
function printScanTable(results: ModelScanResult[], runtime: RuntimeEnv) {
const header = [
pad("Model", MODEL_PAD),
pad("Tool", 10),
pad("Image", 10),
pad("Ctx", CTX_PAD),
pad("Params", 8),
"Notes",
].join(" ");
runtime.log(header);
for (const entry of results) {
const modelLabel = pad(truncate(entry.modelRef, MODEL_PAD), MODEL_PAD);
const toolLabel = pad(
entry.tool.ok ? formatMs(entry.tool.latencyMs) : "fail",
10,
);
const imageLabel = pad(
entry.image.ok
? formatMs(entry.image.latencyMs)
: entry.image.skipped
? "skip"
: "fail",
10,
);
const ctxLabel = pad(formatTokenK(entry.contextLength), CTX_PAD);
const paramsLabel = pad(
entry.inferredParamB ? `${entry.inferredParamB}b` : "-",
8,
);
const notes = entry.modality ? `modality:${entry.modality}` : "";
runtime.log(
[modelLabel, toolLabel, imageLabel, ctxLabel, paramsLabel, notes].join(
" ",
),
);
}
}
export async function modelsScanCommand(
opts: {
minParams?: string;
maxAgeDays?: string;
provider?: string;
maxCandidates?: string;
timeout?: string;
concurrency?: string;
yes?: boolean;
input?: boolean;
setDefault?: boolean;
json?: boolean;
},
runtime: RuntimeEnv,
) {
const minParams = opts.minParams ? Number(opts.minParams) : undefined;
if (minParams !== undefined && (!Number.isFinite(minParams) || minParams < 0)) {
throw new Error("--min-params must be >= 0");
}
const maxAgeDays = opts.maxAgeDays ? Number(opts.maxAgeDays) : undefined;
if (maxAgeDays !== undefined && (!Number.isFinite(maxAgeDays) || maxAgeDays < 0)) {
throw new Error("--max-age-days must be >= 0");
}
const maxCandidates = opts.maxCandidates
? Number(opts.maxCandidates)
: 6;
if (!Number.isFinite(maxCandidates) || maxCandidates <= 0) {
throw new Error("--max-candidates must be > 0");
}
const timeout = opts.timeout ? Number(opts.timeout) : undefined;
if (timeout !== undefined && (!Number.isFinite(timeout) || timeout <= 0)) {
throw new Error("--timeout must be > 0");
}
const concurrency = opts.concurrency ? Number(opts.concurrency) : undefined;
if (concurrency !== undefined && (!Number.isFinite(concurrency) || concurrency <= 0)) {
throw new Error("--concurrency must be > 0");
}
const authStorage = discoverAuthStorage(resolveClawdbotAgentDir());
const storedKey = await authStorage.getApiKey("openrouter");
const results = await scanOpenRouterModels({
apiKey: storedKey ?? undefined,
minParamB: minParams,
maxAgeDays,
providerFilter: opts.provider,
timeoutMs: timeout,
concurrency,
});
const toolOk = results.filter((entry) => entry.tool.ok);
if (toolOk.length === 0) {
throw new Error("No tool-capable OpenRouter free models found.");
}
const sorted = sortScanResults(toolOk);
const imagePreferred = sorted.filter((entry) => entry.image.ok);
const preselectPool = imagePreferred.length > 0 ? imagePreferred : sorted;
const preselected = preselectPool
.slice(0, Math.floor(maxCandidates))
.map((entry) => entry.modelRef);
if (!opts.json) {
printScanSummary(results, runtime);
printScanTable(sorted, runtime);
}
const noInput = opts.input === false;
const canPrompt = process.stdin.isTTY && !opts.yes && !noInput && !opts.json;
let selected: string[] = preselected;
if (canPrompt) {
const selection = await multiselect({
message: "Select fallback models (ordered)",
options: sorted.map((entry) => ({
value: entry.modelRef,
label: entry.modelRef,
hint: buildScanHint(entry),
})),
initialValues: preselected,
});
if (isCancel(selection)) {
cancel("Model scan cancelled.");
runtime.exit(0);
}
selected = selection as string[];
} else if (!process.stdin.isTTY && !opts.yes && !noInput && !opts.json) {
throw new Error("Non-interactive scan: pass --yes to apply defaults.");
}
if (selected.length === 0) {
throw new Error("No models selected for fallbacks.");
}
const updated = await updateConfig((cfg) => {
const next = {
...cfg,
agent: {
...cfg.agent,
modelFallbacks: selected,
...(opts.setDefault ? { model: selected[0] } : {}),
},
};
return next;
});
const allowlist = buildAllowlistSet(updated);
const allowlistMissing =
allowlist.size > 0
? selected.filter((entry) => !allowlist.has(entry))
: [];
if (opts.json) {
runtime.log(
JSON.stringify(
{
selected,
setDefault: Boolean(opts.setDefault),
results,
warnings:
allowlistMissing.length > 0
? [
`Selected models not in agent.allowedModels: ${allowlistMissing.join(", ")}`,
]
: [],
},
null,
2,
),
);
return;
}
if (allowlistMissing.length > 0) {
runtime.log(
warn(
`Warning: ${allowlistMissing.length} selected models are not in agent.allowedModels and will be ignored by fallback: ${allowlistMissing.join(", ")}`,
),
);
}
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Fallbacks: ${selected.join(", ")}`);
if (opts.setDefault) {
runtime.log(`Default model: ${selected[0]}`);
}
}
-29
View File
@@ -1,29 +0,0 @@
import { CONFIG_PATH_CLAWDBOT } from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js";
import { buildAllowlistSet, modelKey, resolveModelTarget, updateConfig } from "./shared.js";
export async function modelsSetCommand(
modelRaw: string,
runtime: RuntimeEnv,
) {
const updated = await updateConfig((cfg) => {
const resolved = resolveModelTarget({ raw: modelRaw, cfg });
const allowlist = buildAllowlistSet(cfg);
if (allowlist.size > 0) {
const key = modelKey(resolved.provider, resolved.model);
if (!allowlist.has(key)) {
throw new Error(`Model ${key} is not in agent.allowedModels.`);
}
}
return {
...cfg,
agent: {
...cfg.agent,
model: `${resolved.provider}/${resolved.model}`,
},
};
});
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Default model: ${updated.agent?.model ?? modelRaw}`);
}
-95
View File
@@ -1,95 +0,0 @@
import {
DEFAULT_MODEL,
DEFAULT_PROVIDER,
} from "../../agents/defaults.js";
import {
buildModelAliasIndex,
modelKey,
parseModelRef,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import {
readConfigFileSnapshot,
writeConfigFile,
type ClawdbotConfig,
} from "../../config/config.js";
export const ensureFlagCompatibility = (opts: {
json?: boolean;
plain?: boolean;
}) => {
if (opts.json && opts.plain) {
throw new Error("Choose either --json or --plain, not both.");
}
};
export const formatTokenK = (value?: number | null) => {
if (!value || !Number.isFinite(value)) return "-";
if (value < 1024) return `${Math.round(value)}`;
return `${Math.round(value / 1024)}k`;
};
export const formatMs = (value?: number | null) => {
if (value === null || value === undefined) return "-";
if (!Number.isFinite(value)) return "-";
if (value < 1000) return `${Math.round(value)}ms`;
return `${Math.round(value / 100) / 10}s`;
};
export async function updateConfig(
mutator: (cfg: ClawdbotConfig) => ClawdbotConfig,
): Promise<ClawdbotConfig> {
const snapshot = await readConfigFileSnapshot();
if (!snapshot.valid) {
const issues = snapshot.issues
.map((issue) => `- ${issue.path}: ${issue.message}`)
.join("\n");
throw new Error(`Invalid config at ${snapshot.path}\n${issues}`);
}
const next = mutator(snapshot.config);
await writeConfigFile(next);
return next;
}
export function resolveModelTarget(params: {
raw: string;
cfg: ClawdbotConfig;
}): { provider: string; model: string } {
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg,
defaultProvider: DEFAULT_PROVIDER,
});
const resolved = resolveModelRefFromString({
raw: params.raw,
defaultProvider: DEFAULT_PROVIDER,
aliasIndex,
});
if (!resolved) {
throw new Error(`Invalid model reference: ${params.raw}`);
}
return resolved.ref;
}
export function buildAllowlistSet(cfg: ClawdbotConfig): Set<string> {
const allowed = new Set<string>();
for (const raw of cfg.agent?.allowedModels ?? []) {
const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER);
if (!parsed) continue;
allowed.add(modelKey(parsed.provider, parsed.model));
}
return allowed;
}
export function normalizeAlias(alias: string): string {
const trimmed = alias.trim();
if (!trimmed) throw new Error("Alias cannot be empty.");
if (!/^[A-Za-z0-9_.:-]+$/.test(trimmed)) {
throw new Error(
"Alias must use letters, numbers, dots, underscores, colons, or dashes.",
);
}
return trimmed;
}
export { modelKey };
export { DEFAULT_MODEL, DEFAULT_PROVIDER };
-3
View File
@@ -88,7 +88,6 @@ const FIELD_LABELS: Record<string, string> = {
"gateway.reload.debounceMs": "Config Reload Debounce (ms)",
"agent.workspace": "Workspace",
"agent.model": "Default Model",
"agent.modelFallbacks": "Model Fallbacks",
"ui.seamColor": "Accent Color",
"browser.controlUrl": "Browser Control URL",
"session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns",
@@ -112,8 +111,6 @@ const FIELD_HELP: Record<string, string> = {
'Hot reload strategy for config changes ("hybrid" recommended).',
"gateway.reload.debounceMs":
"Debounce window (ms) before applying config changes.",
"agent.modelFallbacks":
"Ordered fallback models (provider/model). Used when the primary model fails.",
"session.agentToAgent.maxPingPongTurns":
"Max reply-back turns between requester and target (0–5).",
};
-2
View File
@@ -50,7 +50,6 @@ export type SessionEntry = {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
modelProvider?: string;
model?: string;
contextTokens?: number;
displayName?: string;
@@ -336,7 +335,6 @@ export async function updateLastRoute(params: {
inputTokens: existing?.inputTokens,
outputTokens: existing?.outputTokens,
totalTokens: existing?.totalTokens,
modelProvider: existing?.modelProvider,
model: existing?.model,
contextTokens: existing?.contextTokens,
displayName: existing?.displayName,
-2
View File
@@ -666,8 +666,6 @@ export type ClawdbotConfig = {
allowedModels?: string[];
/** Optional model aliases for /model (alias -> provider/model). */
modelAliases?: Record<string, string>;
/** Ordered fallback models (provider/model). */
modelFallbacks?: string[];
/** Optional display-only context window override (used for % in status UIs). */
contextTokens?: number;
/** Default thinking level when no /think directive is present. */
-1
View File
@@ -366,7 +366,6 @@ export const ClawdbotSchema = z.object({
workspace: z.string().optional(),
allowedModels: z.array(z.string()).optional(),
modelAliases: z.record(z.string(), z.string()).optional(),
modelFallbacks: z.array(z.string()).optional(),
contextTokens: z.number().int().positive().optional(),
thinkingDefault: z
.union([
+17 -33
View File
@@ -10,7 +10,6 @@ import {
resolveConfiguredModelRef,
resolveThinkingDefault,
} from "../agents/model-selection.js";
import { runWithModelFallback } from "../agents/model-fallback.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
import {
@@ -265,8 +264,6 @@ export async function runCronIsolatedAgentTurn(params: {
}
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
let fallbackProvider = provider;
let fallbackModel = model;
try {
const sessionFile = resolveSessionTranscriptPath(
cronSession.sessionEntry.sessionId,
@@ -275,34 +272,25 @@ export async function runCronIsolatedAgentTurn(params: {
sessionKey: params.sessionKey,
});
const surface = resolvedDelivery.channel;
const fallbackResult = await runWithModelFallback({
cfg: params.cfg,
runResult = await runEmbeddedPiAgent({
sessionId: cronSession.sessionEntry.sessionId,
sessionKey: params.sessionKey,
surface,
sessionFile,
workspaceDir,
config: params.cfg,
skillsSnapshot,
prompt: commandBody,
lane: params.lane ?? "cron",
provider,
model,
run: (providerOverride, modelOverride) =>
runEmbeddedPiAgent({
sessionId: cronSession.sessionEntry.sessionId,
sessionKey: params.sessionKey,
surface,
sessionFile,
workspaceDir,
config: params.cfg,
skillsSnapshot,
prompt: commandBody,
lane: params.lane ?? "cron",
provider: providerOverride,
model: modelOverride,
thinkLevel,
verboseLevel:
(cronSession.sessionEntry.verboseLevel as "on" | "off" | undefined) ??
(agentCfg?.verboseDefault as "on" | "off" | undefined),
timeoutMs,
runId: cronSession.sessionEntry.sessionId,
}),
thinkLevel,
verboseLevel:
(cronSession.sessionEntry.verboseLevel as "on" | "off" | undefined) ??
(agentCfg?.verboseDefault as "on" | "off" | undefined),
timeoutMs,
runId: cronSession.sessionEntry.sessionId,
});
runResult = fallbackResult.result;
fallbackProvider = fallbackResult.provider;
fallbackModel = fallbackResult.model;
} catch (err) {
return { status: "error", error: String(err) };
}
@@ -312,16 +300,12 @@ export async function runCronIsolatedAgentTurn(params: {
// Update token+model fields in the session store.
{
const usage = runResult.meta.agentMeta?.usage;
const modelUsed =
runResult.meta.agentMeta?.model ?? fallbackModel ?? model;
const providerUsed =
runResult.meta.agentMeta?.provider ?? fallbackProvider ?? provider;
const modelUsed = runResult.meta.agentMeta?.model ?? model;
const contextTokens =
agentCfg?.contextTokens ??
lookupContextTokens(modelUsed) ??
DEFAULT_CONTEXT_TOKENS;
cronSession.sessionEntry.modelProvider = providerUsed;
cronSession.sessionEntry.model = modelUsed;
cronSession.sessionEntry.contextTokens = contextTokens;
if (usage) {