feat: sticky auth profile rotation + usage headers
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
|
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
|
||||||
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
|
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
|
||||||
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
|
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
|
||||||
|
- Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in `/status` and `clawdbot models status`, and update docs.
|
||||||
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
|
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
|
||||||
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
||||||
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
|
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
|
||||||
@@ -34,8 +35,9 @@
|
|||||||
- Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.
|
- Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.
|
||||||
- Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.
|
- Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.
|
||||||
- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging.
|
- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging.
|
||||||
- Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor`.
|
- Security: expand `clawdbot security audit` checks (model hygiene, config includes, plugin allowlists, exposure matrix) and extend `--fix` to tighten more sensitive state paths.
|
||||||
- Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor` (includes browser control exposure checks).
|
- Security: add `SECURITY.md` reporting policy.
|
||||||
|
- Channels: add Matrix plugin (external) with docs + onboarding hooks.
|
||||||
- Plugins: add Zalo channel plugin with gateway HTTP hooks and onboarding install prompt. (#854) — thanks @longmaba.
|
- Plugins: add Zalo channel plugin with gateway HTTP hooks and onboarding install prompt. (#854) — thanks @longmaba.
|
||||||
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`.
|
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`.
|
||||||
- Docs: expand gateway security hardening guidance and incident response checklist.
|
- Docs: expand gateway security hardening guidance and incident response checklist.
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ clawdbot models set <model-or-alias>
|
|||||||
clawdbot models scan
|
clawdbot models scan
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`clawdbot models status` shows the resolved default/fallbacks plus an auth overview.
|
||||||
|
When provider usage snapshots are available, the OAuth/token status section includes
|
||||||
|
provider usage headers.
|
||||||
|
|
||||||
## Aliases + fallbacks
|
## Aliases + fallbacks
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -36,4 +40,3 @@ clawdbot models auth add
|
|||||||
clawdbot models auth setup-token
|
clawdbot models auth setup-token
|
||||||
clawdbot models auth paste-token
|
clawdbot models auth paste-token
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,17 @@ If no explicit order is configured, Clawdbot uses a round‑robin order:
|
|||||||
- **Secondary key:** `usageStats.lastUsed` (oldest first, within each type).
|
- **Secondary key:** `usageStats.lastUsed` (oldest first, within each type).
|
||||||
- **Cooldown/disabled profiles** are moved to the end, ordered by soonest expiry.
|
- **Cooldown/disabled profiles** are moved to the end, ordered by soonest expiry.
|
||||||
|
|
||||||
|
### Session stickiness (cache-friendly)
|
||||||
|
|
||||||
|
Clawdbot **pins the chosen auth profile per session** to keep provider caches warm.
|
||||||
|
It does **not** rotate on every request. The pinned profile is reused until:
|
||||||
|
- the session is reset (`/new` / `/reset`)
|
||||||
|
- a compaction completes (compaction count increments)
|
||||||
|
- the profile is in cooldown/disabled
|
||||||
|
|
||||||
|
Manual selection via `/model …@<profileId>` sets a **user override** for that session
|
||||||
|
and is not auto‑rotated until a new session starts.
|
||||||
|
|
||||||
### Why OAuth can “look lost”
|
### Why OAuth can “look lost”
|
||||||
|
|
||||||
If you have both an OAuth profile and an API key profile for the same provider, round‑robin can switch between them across messages unless pinned. To force a single profile:
|
If you have both an OAuth profile and an API key profile for the same provider, round‑robin can switch between them across messages unless pinned. To force a single profile:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ read_when:
|
|||||||
- No estimated costs; only the provider-reported windows.
|
- No estimated costs; only the provider-reported windows.
|
||||||
|
|
||||||
## Where it shows up
|
## Where it shows up
|
||||||
- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only) and provider quota windows when available.
|
- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only). When OAuth/token profiles exist, the **OAuth/token status block** includes provider usage headers (when available).
|
||||||
- `/cost on|off` in chats: toggles per‑response usage lines (OAuth shows tokens only).
|
- `/cost on|off` in chats: toggles per‑response usage lines (OAuth shows tokens only).
|
||||||
- CLI: `clawdbot status --usage` prints a full per-provider breakdown.
|
- CLI: `clawdbot status --usage` prints a full per-provider breakdown.
|
||||||
- CLI: `clawdbot channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
|
- CLI: `clawdbot channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
|
||||||
|
|||||||
@@ -455,6 +455,44 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Anthropic subscription + API key, MiniMax fallback
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
auth: {
|
||||||
|
profiles: {
|
||||||
|
"anthropic:subscription": {
|
||||||
|
provider: "anthropic",
|
||||||
|
mode: "oauth",
|
||||||
|
email: "user@example.com"
|
||||||
|
},
|
||||||
|
"anthropic:api": {
|
||||||
|
provider: "anthropic",
|
||||||
|
mode: "api_key"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
order: {
|
||||||
|
anthropic: ["anthropic:subscription", "anthropic:api"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
models: {
|
||||||
|
providers: {
|
||||||
|
minimax: {
|
||||||
|
baseUrl: "https://api.minimax.io/anthropic",
|
||||||
|
api: "anthropic-messages",
|
||||||
|
apiKey: "${MINIMAX_API_KEY}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
agent: {
|
||||||
|
workspace: "~/clawd",
|
||||||
|
model: {
|
||||||
|
primary: "anthropic/claude-opus-4-5",
|
||||||
|
fallbacks: ["minimax/MiniMax-M2.1"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Work bot (restricted access)
|
### Work bot (restricted access)
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ They run immediately, are stripped before the model sees the message, and the re
|
|||||||
Text + native (when enabled):
|
Text + native (when enabled):
|
||||||
- `/help`
|
- `/help`
|
||||||
- `/commands`
|
- `/commands`
|
||||||
- `/status` (show current status; includes a short provider usage/quota line when available)
|
- `/status` (show current status; includes provider usage/quota when available, plus OAuth/token status block when OAuth profiles exist)
|
||||||
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
||||||
- `/usage` (alias: `/status`)
|
- `/usage` (alias: `/status`)
|
||||||
- `/whoami` (show your sender id; alias: `/id`)
|
- `/whoami` (show your sender id; alias: `/id`)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
import { resolveAgentDir } from "../../agents/agent-scope.js";
|
import { resolveAgentDir } from "../../agents/agent-scope.js";
|
||||||
|
import { buildAuthHealthSummary, formatRemainingShort } from "../../agents/auth-health.js";
|
||||||
import {
|
import {
|
||||||
ensureAuthProfileStore,
|
ensureAuthProfileStore,
|
||||||
resolveAuthProfileDisplayLabel,
|
resolveAuthProfileDisplayLabel,
|
||||||
@@ -28,9 +29,10 @@ import {
|
|||||||
updateSessionStore,
|
updateSessionStore,
|
||||||
} from "../../config/sessions.js";
|
} from "../../config/sessions.js";
|
||||||
import {
|
import {
|
||||||
formatUsageSummaryLine,
|
formatUsageWindowSummary,
|
||||||
loadProviderUsageSummary,
|
loadProviderUsageSummary,
|
||||||
resolveUsageProviderId,
|
resolveUsageProviderId,
|
||||||
|
type UsageProviderId,
|
||||||
} from "../../infra/provider-usage.js";
|
} from "../../infra/provider-usage.js";
|
||||||
import {
|
import {
|
||||||
buildAgentMainSessionKey,
|
buildAgentMainSessionKey,
|
||||||
@@ -257,10 +259,14 @@ export function createSessionStatusTool(opts?: {
|
|||||||
delete nextEntry.providerOverride;
|
delete nextEntry.providerOverride;
|
||||||
delete nextEntry.modelOverride;
|
delete nextEntry.modelOverride;
|
||||||
delete nextEntry.authProfileOverride;
|
delete nextEntry.authProfileOverride;
|
||||||
|
delete nextEntry.authProfileOverrideSource;
|
||||||
|
delete nextEntry.authProfileOverrideCompactionCount;
|
||||||
} else {
|
} else {
|
||||||
nextEntry.providerOverride = selection.provider;
|
nextEntry.providerOverride = selection.provider;
|
||||||
nextEntry.modelOverride = selection.model;
|
nextEntry.modelOverride = selection.model;
|
||||||
delete nextEntry.authProfileOverride;
|
delete nextEntry.authProfileOverride;
|
||||||
|
delete nextEntry.authProfileOverrideSource;
|
||||||
|
delete nextEntry.authProfileOverrideCompactionCount;
|
||||||
}
|
}
|
||||||
store[resolved.key] = nextEntry;
|
store[resolved.key] = nextEntry;
|
||||||
await updateSessionStore(storePath, (nextStore) => {
|
await updateSessionStore(storePath, (nextStore) => {
|
||||||
@@ -277,24 +283,48 @@ export function createSessionStatusTool(opts?: {
|
|||||||
defaultModel: DEFAULT_MODEL,
|
defaultModel: DEFAULT_MODEL,
|
||||||
});
|
});
|
||||||
const providerForCard = resolved.entry.providerOverride?.trim() || configured.provider;
|
const providerForCard = resolved.entry.providerOverride?.trim() || configured.provider;
|
||||||
const usageProvider = resolveUsageProviderId(providerForCard);
|
const authStore = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
|
||||||
let usageLine: string | undefined;
|
const authHealth = buildAuthHealthSummary({
|
||||||
if (usageProvider) {
|
store: authStore,
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
const oauthProfiles = authHealth.profiles.filter(
|
||||||
|
(profile) => profile.type === "oauth" || profile.type === "token",
|
||||||
|
);
|
||||||
|
|
||||||
|
const usageProviders = Array.from(
|
||||||
|
new Set(
|
||||||
|
oauthProfiles
|
||||||
|
.map((profile) => resolveUsageProviderId(profile.provider))
|
||||||
|
.filter((provider): provider is UsageProviderId => Boolean(provider)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const usageByProvider = new Map<string, string>();
|
||||||
|
if (usageProviders.length > 0) {
|
||||||
try {
|
try {
|
||||||
const usageSummary = await loadProviderUsageSummary({
|
const usageSummary = await loadProviderUsageSummary({
|
||||||
timeoutMs: 3500,
|
timeoutMs: 3500,
|
||||||
providers: [usageProvider],
|
providers: usageProviders,
|
||||||
agentDir,
|
agentDir,
|
||||||
});
|
});
|
||||||
const formatted = formatUsageSummaryLine(usageSummary, {
|
for (const snapshot of usageSummary.providers) {
|
||||||
now: Date.now(),
|
const formatted = formatUsageWindowSummary(snapshot, {
|
||||||
});
|
now: Date.now(),
|
||||||
if (formatted) usageLine = formatted;
|
maxWindows: 2,
|
||||||
|
});
|
||||||
|
if (formatted) usageByProvider.set(snapshot.provider, formatted);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const usageProvider = resolveUsageProviderId(providerForCard);
|
||||||
|
const usageLine =
|
||||||
|
oauthProfiles.length === 0 && usageProvider && usageByProvider.has(usageProvider)
|
||||||
|
? `📊 Usage: ${usageByProvider.get(usageProvider)}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const isGroup =
|
const isGroup =
|
||||||
resolved.entry.chatType === "group" ||
|
resolved.entry.chatType === "group" ||
|
||||||
resolved.entry.chatType === "room" ||
|
resolved.entry.chatType === "room" ||
|
||||||
@@ -340,13 +370,53 @@ export function createSessionStatusTool(opts?: {
|
|||||||
includeTranscriptUsage: false,
|
includeTranscriptUsage: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const authStatusLines = (() => {
|
||||||
|
if (oauthProfiles.length === 0) return [];
|
||||||
|
const formatStatus = (status: string) => {
|
||||||
|
if (status === "ok") return "ok";
|
||||||
|
if (status === "static") return "static";
|
||||||
|
if (status === "expiring") return "expiring";
|
||||||
|
if (status === "missing") return "unknown";
|
||||||
|
return "expired";
|
||||||
|
};
|
||||||
|
const profilesByProvider = new Map<string, typeof oauthProfiles>();
|
||||||
|
for (const profile of oauthProfiles) {
|
||||||
|
const current = profilesByProvider.get(profile.provider);
|
||||||
|
if (current) current.push(profile);
|
||||||
|
else profilesByProvider.set(profile.provider, [profile]);
|
||||||
|
}
|
||||||
|
const lines: string[] = ["OAuth/token status"];
|
||||||
|
for (const [provider, profiles] of profilesByProvider) {
|
||||||
|
const usageKey = resolveUsageProviderId(provider);
|
||||||
|
const usage = usageKey ? usageByProvider.get(usageKey) : undefined;
|
||||||
|
const usageSuffix = usage ? ` — usage: ${usage}` : "";
|
||||||
|
lines.push(`- ${provider}${usageSuffix}`);
|
||||||
|
for (const profile of profiles) {
|
||||||
|
const labelText = profile.label || profile.profileId;
|
||||||
|
const status = formatStatus(profile.status);
|
||||||
|
const expiry =
|
||||||
|
profile.status === "static"
|
||||||
|
? ""
|
||||||
|
: profile.expiresAt
|
||||||
|
? ` expires in ${formatRemainingShort(profile.remainingMs)}`
|
||||||
|
: " expires unknown";
|
||||||
|
const source = profile.source !== "store" ? ` (${profile.source})` : "";
|
||||||
|
lines.push(` - ${labelText} ${status}${expiry}${source}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const fullStatusText =
|
||||||
|
authStatusLines.length > 0 ? `${statusText}\n\n${authStatusLines.join("\n")}` : statusText;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: statusText }],
|
content: [{ type: "text", text: fullStatusText }],
|
||||||
details: {
|
details: {
|
||||||
ok: true,
|
ok: true,
|
||||||
sessionKey: resolved.key,
|
sessionKey: resolved.key,
|
||||||
changedModel,
|
changedModel,
|
||||||
statusText,
|
statusText: fullStatusText,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -264,8 +264,12 @@ export async function handleDirectiveOnly(params: {
|
|||||||
}
|
}
|
||||||
if (profileOverride) {
|
if (profileOverride) {
|
||||||
sessionEntry.authProfileOverride = profileOverride;
|
sessionEntry.authProfileOverride = profileOverride;
|
||||||
|
sessionEntry.authProfileOverrideSource = "user";
|
||||||
|
delete sessionEntry.authProfileOverrideCompactionCount;
|
||||||
} else if (directives.hasModelDirective) {
|
} else if (directives.hasModelDirective) {
|
||||||
delete sessionEntry.authProfileOverride;
|
delete sessionEntry.authProfileOverride;
|
||||||
|
delete sessionEntry.authProfileOverrideSource;
|
||||||
|
delete sessionEntry.authProfileOverrideCompactionCount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (directives.hasQueueDirective && directives.queueReset) {
|
if (directives.hasQueueDirective && directives.queueReset) {
|
||||||
|
|||||||
@@ -156,8 +156,12 @@ export async function persistInlineDirectives(params: {
|
|||||||
}
|
}
|
||||||
if (profileOverride) {
|
if (profileOverride) {
|
||||||
sessionEntry.authProfileOverride = profileOverride;
|
sessionEntry.authProfileOverride = profileOverride;
|
||||||
|
sessionEntry.authProfileOverrideSource = "user";
|
||||||
|
delete sessionEntry.authProfileOverrideCompactionCount;
|
||||||
} else if (directives.hasModelDirective) {
|
} else if (directives.hasModelDirective) {
|
||||||
delete sessionEntry.authProfileOverride;
|
delete sessionEntry.authProfileOverride;
|
||||||
|
delete sessionEntry.authProfileOverrideSource;
|
||||||
|
delete sessionEntry.authProfileOverrideCompactionCount;
|
||||||
}
|
}
|
||||||
provider = resolved.ref.provider;
|
provider = resolved.ref.provider;
|
||||||
model = resolved.ref.model;
|
model = resolved.ref.model;
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import {
|
|||||||
isEmbeddedPiRunStreaming,
|
isEmbeddedPiRunStreaming,
|
||||||
resolveEmbeddedSessionLane,
|
resolveEmbeddedSessionLane,
|
||||||
} from "../../agents/pi-embedded.js";
|
} from "../../agents/pi-embedded.js";
|
||||||
|
import {
|
||||||
|
ensureAuthProfileStore,
|
||||||
|
isProfileInCooldown,
|
||||||
|
resolveAuthProfileOrder,
|
||||||
|
} from "../../agents/auth-profiles.js";
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import {
|
import {
|
||||||
resolveSessionFilePath,
|
resolveSessionFilePath,
|
||||||
@@ -97,6 +102,86 @@ type RunPreparedReplyParams = {
|
|||||||
abortedLastRun: boolean;
|
abortedLastRun: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function resolveSessionAuthProfileOverride(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
provider: string;
|
||||||
|
agentDir: string;
|
||||||
|
sessionEntry?: SessionEntry;
|
||||||
|
sessionStore?: Record<string, SessionEntry>;
|
||||||
|
sessionKey?: string;
|
||||||
|
storePath?: string;
|
||||||
|
isNewSession: boolean;
|
||||||
|
}): Promise<string | undefined> {
|
||||||
|
const {
|
||||||
|
cfg,
|
||||||
|
provider,
|
||||||
|
agentDir,
|
||||||
|
sessionEntry,
|
||||||
|
sessionStore,
|
||||||
|
sessionKey,
|
||||||
|
storePath,
|
||||||
|
isNewSession,
|
||||||
|
} = params;
|
||||||
|
if (!sessionEntry || !sessionStore || !sessionKey) return sessionEntry?.authProfileOverride;
|
||||||
|
|
||||||
|
const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
|
||||||
|
const order = resolveAuthProfileOrder({ cfg, store, provider });
|
||||||
|
if (order.length === 0) return sessionEntry.authProfileOverride;
|
||||||
|
|
||||||
|
const pickFirstAvailable = () =>
|
||||||
|
order.find((profileId) => !isProfileInCooldown(store, profileId)) ?? order[0];
|
||||||
|
const pickNextAvailable = (current: string) => {
|
||||||
|
const startIndex = order.indexOf(current);
|
||||||
|
if (startIndex < 0) return pickFirstAvailable();
|
||||||
|
for (let offset = 1; offset <= order.length; offset += 1) {
|
||||||
|
const candidate = order[(startIndex + offset) % order.length];
|
||||||
|
if (!isProfileInCooldown(store, candidate)) return candidate;
|
||||||
|
}
|
||||||
|
return order[startIndex] ?? order[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const compactionCount = sessionEntry.compactionCount ?? 0;
|
||||||
|
const storedCompaction =
|
||||||
|
typeof sessionEntry.authProfileOverrideCompactionCount === "number"
|
||||||
|
? sessionEntry.authProfileOverrideCompactionCount
|
||||||
|
: compactionCount;
|
||||||
|
|
||||||
|
let current = sessionEntry.authProfileOverride?.trim();
|
||||||
|
if (current && !order.includes(current)) current = undefined;
|
||||||
|
|
||||||
|
const source = sessionEntry.authProfileOverrideSource ?? (current ? "user" : undefined);
|
||||||
|
if (source === "user" && current && !isNewSession) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
let next = current;
|
||||||
|
if (isNewSession) {
|
||||||
|
next = current ? pickNextAvailable(current) : pickFirstAvailable();
|
||||||
|
} else if (current && compactionCount > storedCompaction) {
|
||||||
|
next = pickNextAvailable(current);
|
||||||
|
} else if (!current || isProfileInCooldown(store, current)) {
|
||||||
|
next = pickFirstAvailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!next) return current;
|
||||||
|
const shouldPersist =
|
||||||
|
next !== sessionEntry.authProfileOverride ||
|
||||||
|
sessionEntry.authProfileOverrideSource !== "auto" ||
|
||||||
|
sessionEntry.authProfileOverrideCompactionCount !== compactionCount;
|
||||||
|
if (shouldPersist) {
|
||||||
|
sessionEntry.authProfileOverride = next;
|
||||||
|
sessionEntry.authProfileOverrideSource = "auto";
|
||||||
|
sessionEntry.authProfileOverrideCompactionCount = compactionCount;
|
||||||
|
sessionEntry.updatedAt = Date.now();
|
||||||
|
sessionStore[sessionKey] = sessionEntry;
|
||||||
|
if (storePath) {
|
||||||
|
await saveSessionStore(storePath, sessionStore);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
export async function runPreparedReply(
|
export async function runPreparedReply(
|
||||||
params: RunPreparedReplyParams,
|
params: RunPreparedReplyParams,
|
||||||
): Promise<ReplyPayload | ReplyPayload[] | undefined> {
|
): Promise<ReplyPayload | ReplyPayload[] | undefined> {
|
||||||
@@ -314,7 +399,16 @@ export async function runPreparedReply(
|
|||||||
resolvedQueue.mode === "followup" ||
|
resolvedQueue.mode === "followup" ||
|
||||||
resolvedQueue.mode === "collect" ||
|
resolvedQueue.mode === "collect" ||
|
||||||
resolvedQueue.mode === "steer-backlog";
|
resolvedQueue.mode === "steer-backlog";
|
||||||
const authProfileId = sessionEntry?.authProfileOverride;
|
const authProfileId = await resolveSessionAuthProfileOverride({
|
||||||
|
cfg,
|
||||||
|
provider,
|
||||||
|
agentDir,
|
||||||
|
sessionEntry,
|
||||||
|
sessionStore,
|
||||||
|
sessionKey,
|
||||||
|
storePath,
|
||||||
|
isNewSession,
|
||||||
|
});
|
||||||
const followupRun = {
|
const followupRun = {
|
||||||
prompt: queuedBody,
|
prompt: queuedBody,
|
||||||
messageId: sessionCtx.MessageSid,
|
messageId: sessionCtx.MessageSid,
|
||||||
|
|||||||
@@ -217,6 +217,8 @@ export async function createModelSelectionState(params: {
|
|||||||
const profile = store.profiles[sessionEntry.authProfileOverride];
|
const profile = store.profiles[sessionEntry.authProfileOverride];
|
||||||
if (!profile || profile.provider !== provider) {
|
if (!profile || profile.provider !== provider) {
|
||||||
delete sessionEntry.authProfileOverride;
|
delete sessionEntry.authProfileOverride;
|
||||||
|
delete sessionEntry.authProfileOverrideSource;
|
||||||
|
delete sessionEntry.authProfileOverrideCompactionCount;
|
||||||
sessionEntry.updatedAt = Date.now();
|
sessionEntry.updatedAt = Date.now();
|
||||||
sessionStore[sessionKey] = sessionEntry;
|
sessionStore[sessionKey] = sessionEntry;
|
||||||
if (storePath) {
|
if (storePath) {
|
||||||
|
|||||||
@@ -286,6 +286,8 @@ export async function agentCommand(
|
|||||||
const profile = store.profiles[authProfileId];
|
const profile = store.profiles[authProfileId];
|
||||||
if (!profile || profile.provider !== provider) {
|
if (!profile || profile.provider !== provider) {
|
||||||
delete entry.authProfileOverride;
|
delete entry.authProfileOverride;
|
||||||
|
delete entry.authProfileOverrideSource;
|
||||||
|
delete entry.authProfileOverrideCompactionCount;
|
||||||
entry.updatedAt = Date.now();
|
entry.updatedAt = Date.now();
|
||||||
if (sessionStore && sessionKey) {
|
if (sessionStore && sessionKey) {
|
||||||
sessionStore[sessionKey] = entry;
|
sessionStore[sessionKey] = entry;
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ import { resolveEnvApiKey } from "../../agents/model-auth.js";
|
|||||||
import { parseModelRef, resolveConfiguredModelRef } from "../../agents/model-selection.js";
|
import { parseModelRef, resolveConfiguredModelRef } from "../../agents/model-selection.js";
|
||||||
import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js";
|
import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js";
|
||||||
import { getShellEnvAppliedKeys, shouldEnableShellEnvFallback } from "../../infra/shell-env.js";
|
import { getShellEnvAppliedKeys, shouldEnableShellEnvFallback } from "../../infra/shell-env.js";
|
||||||
|
import {
|
||||||
|
formatUsageWindowSummary,
|
||||||
|
loadProviderUsageSummary,
|
||||||
|
resolveUsageProviderId,
|
||||||
|
type UsageProviderId,
|
||||||
|
} from "../../infra/provider-usage.js";
|
||||||
import type { RuntimeEnv } from "../../runtime.js";
|
import type { RuntimeEnv } from "../../runtime.js";
|
||||||
import { colorize, theme } from "../../terminal/theme.js";
|
import { colorize, theme } from "../../terminal/theme.js";
|
||||||
import { shortenHomePath } from "../../utils.js";
|
import { shortenHomePath } from "../../utils.js";
|
||||||
@@ -402,6 +408,32 @@ export async function modelsStatusCommand(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const usageByProvider = new Map<string, string>();
|
||||||
|
const usageProviders = Array.from(
|
||||||
|
new Set(
|
||||||
|
oauthProfiles
|
||||||
|
.map((profile) => resolveUsageProviderId(profile.provider))
|
||||||
|
.filter((provider): provider is UsageProviderId => Boolean(provider)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (usageProviders.length > 0) {
|
||||||
|
try {
|
||||||
|
const usageSummary = await loadProviderUsageSummary({
|
||||||
|
providers: usageProviders,
|
||||||
|
agentDir,
|
||||||
|
timeoutMs: 3500,
|
||||||
|
});
|
||||||
|
for (const snapshot of usageSummary.providers) {
|
||||||
|
const formatted = formatUsageWindowSummary(snapshot, { now: Date.now(), maxWindows: 2 });
|
||||||
|
if (formatted) {
|
||||||
|
usageByProvider.set(snapshot.provider, formatted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore usage failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const formatStatus = (status: string) => {
|
const formatStatus = (status: string) => {
|
||||||
if (status === "ok") return colorize(rich, theme.success, "ok");
|
if (status === "ok") return colorize(rich, theme.success, "ok");
|
||||||
if (status === "static") return colorize(rich, theme.muted, "static");
|
if (status === "static") return colorize(rich, theme.muted, "static");
|
||||||
@@ -410,19 +442,32 @@ export async function modelsStatusCommand(
|
|||||||
return colorize(rich, theme.error, "expired");
|
return colorize(rich, theme.error, "expired");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const profilesByProvider = new Map<string, typeof oauthProfiles>();
|
||||||
for (const profile of oauthProfiles) {
|
for (const profile of oauthProfiles) {
|
||||||
const labelText = profile.label || profile.profileId;
|
const current = profilesByProvider.get(profile.provider);
|
||||||
const label = colorize(rich, theme.accent, labelText);
|
if (current) current.push(profile);
|
||||||
const status = formatStatus(profile.status);
|
else profilesByProvider.set(profile.provider, [profile]);
|
||||||
const expiry =
|
}
|
||||||
profile.status === "static"
|
|
||||||
? ""
|
for (const [provider, profiles] of profilesByProvider) {
|
||||||
: profile.expiresAt
|
const usageKey = resolveUsageProviderId(provider);
|
||||||
? ` expires in ${formatRemainingShort(profile.remainingMs)}`
|
const usage = usageKey ? usageByProvider.get(usageKey) : undefined;
|
||||||
: " expires unknown";
|
const usageSuffix = usage ? colorize(rich, theme.muted, ` usage: ${usage}`) : "";
|
||||||
const source =
|
runtime.log(`- ${colorize(rich, theme.heading, provider)}${usageSuffix}`);
|
||||||
profile.source !== "store" ? colorize(rich, theme.muted, ` (${profile.source})`) : "";
|
for (const profile of profiles) {
|
||||||
runtime.log(`- ${label} ${status}${expiry}${source}`);
|
const labelText = profile.label || profile.profileId;
|
||||||
|
const label = colorize(rich, theme.accent, labelText);
|
||||||
|
const status = formatStatus(profile.status);
|
||||||
|
const expiry =
|
||||||
|
profile.status === "static"
|
||||||
|
? ""
|
||||||
|
: profile.expiresAt
|
||||||
|
? ` expires in ${formatRemainingShort(profile.remainingMs)}`
|
||||||
|
: " expires unknown";
|
||||||
|
const source =
|
||||||
|
profile.source !== "store" ? colorize(rich, theme.muted, ` (${profile.source})`) : "";
|
||||||
|
runtime.log(` - ${label} ${status}${expiry}${source}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.check) runtime.exit(checkStatus);
|
if (opts.check) runtime.exit(checkStatus);
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export type SessionEntry = {
|
|||||||
providerOverride?: string;
|
providerOverride?: string;
|
||||||
modelOverride?: string;
|
modelOverride?: string;
|
||||||
authProfileOverride?: string;
|
authProfileOverride?: string;
|
||||||
|
authProfileOverrideSource?: "auto" | "user";
|
||||||
|
authProfileOverrideCompactionCount?: number;
|
||||||
groupActivation?: "mention" | "always";
|
groupActivation?: "mention" | "always";
|
||||||
groupActivationNeedsSystemIntro?: boolean;
|
groupActivationNeedsSystemIntro?: boolean;
|
||||||
sendPolicy?: "allow" | "deny";
|
sendPolicy?: "allow" | "deny";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { clampPercent } from "./provider-usage.shared.js";
|
import { clampPercent } from "./provider-usage.shared.js";
|
||||||
import type { UsageSummary, UsageWindow } from "./provider-usage.types.js";
|
import type { ProviderUsageSnapshot, UsageSummary, UsageWindow } from "./provider-usage.types.js";
|
||||||
|
|
||||||
function formatResetRemaining(targetMs?: number, now?: number): string | null {
|
function formatResetRemaining(targetMs?: number, now?: number): string | null {
|
||||||
if (!targetMs) return null;
|
if (!targetMs) return null;
|
||||||
@@ -35,6 +35,28 @@ function formatWindowShort(window: UsageWindow, now?: number): string {
|
|||||||
return `${remaining.toFixed(0)}% left (${window.label}${resetSuffix})`;
|
return `${remaining.toFixed(0)}% left (${window.label}${resetSuffix})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatUsageWindowSummary(
|
||||||
|
snapshot: ProviderUsageSnapshot,
|
||||||
|
opts?: { now?: number; maxWindows?: number; includeResets?: boolean },
|
||||||
|
): string | null {
|
||||||
|
if (snapshot.error) return `error: ${snapshot.error}`;
|
||||||
|
if (snapshot.windows.length === 0) return null;
|
||||||
|
const now = opts?.now ?? Date.now();
|
||||||
|
const maxWindows =
|
||||||
|
typeof opts?.maxWindows === "number" && opts.maxWindows > 0
|
||||||
|
? Math.min(opts.maxWindows, snapshot.windows.length)
|
||||||
|
: snapshot.windows.length;
|
||||||
|
const includeResets = opts?.includeResets ?? false;
|
||||||
|
const windows = snapshot.windows.slice(0, maxWindows);
|
||||||
|
const parts = windows.map((window) => {
|
||||||
|
const remaining = clampPercent(100 - window.usedPercent);
|
||||||
|
const reset = includeResets ? formatResetRemaining(window.resetAt, now) : null;
|
||||||
|
const resetSuffix = reset ? ` ⏱${reset}` : "";
|
||||||
|
return `${window.label} ${remaining.toFixed(0)}% left${resetSuffix}`;
|
||||||
|
});
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
export function formatUsageSummaryLine(
|
export function formatUsageSummaryLine(
|
||||||
summary: UsageSummary,
|
summary: UsageSummary,
|
||||||
opts?: { now?: number; maxProviders?: number },
|
opts?: { now?: number; maxProviders?: number },
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
export { formatUsageReportLines, formatUsageSummaryLine } from "./provider-usage.format.js";
|
export {
|
||||||
|
formatUsageReportLines,
|
||||||
|
formatUsageSummaryLine,
|
||||||
|
formatUsageWindowSummary,
|
||||||
|
} from "./provider-usage.format.js";
|
||||||
export { loadProviderUsageSummary } from "./provider-usage.load.js";
|
export { loadProviderUsageSummary } from "./provider-usage.load.js";
|
||||||
export { resolveUsageProviderId } from "./provider-usage.shared.js";
|
export { resolveUsageProviderId } from "./provider-usage.shared.js";
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
Reference in New Issue
Block a user