// CodeMie (AI/Run) provider — https://github.com/codemie-ai/codemie-code
//
// Auth modes (checked in order):
// 1. Env vars – CODEMIE_JWT_TOKEN / CODEMIE_API_KEY (Bearer) or CODEMIE_COOKIE (raw Cookie)
// plus optional CODEMIE_BASE_URL override. For CI/service accounts.
// 2. OAuth SSO – Browser-based login, exactly like CodeMie's own CLI:
// opens {base}/v1/auth/login/{port}, local server catches the callback,
// decodes the base64 token (cookies incl. codemie_access_token JWT),
// resolves the real API URL from /config.js.
// Credentials persist in ~/.pi/agent/auth.json. Startup never
// opens a browser — login happens via /login codemie, or
// automatically when an actual CodeMie request needs a refresh.
// CODEMIE_MODEL – static fallback model id when live model discovery fails.
//
// Billing channel (same account, same session — NOT a second account):
// CodeMie's backend attributes spend to one of several LiteLLM buckets per
// user (see `/codemie-usage`: a plain "Web/Platform" row, a "(cli)" row,
// a "(premium)" row). CONFIRMED by measurement (4 isolated test configs,
// before/after budget_usage comparisons — see README "Two billing
// channels"): the header that actually decides bucket attribution is
// `X-CodeMie-Project` (the account's email/username), PLUS
// `X-CodeMie-Session-ID`/`X-CodeMie-Request-ID` needing to be *validly
// formatted UUIDs* (not necessarily unique per request). Sending only
// `X-CodeMie-Client`/`X-CodeMie-CLI` (no Project, or malformed UUIDs) does
// nothing — confirmed twice. Sending the full set including a correct
// `X-CodeMie-Project` reliably shifts spend into the "(cli)" bucket — also
// confirmed twice, on the SAME SSO-cookie session `codemie` uses, no JWT
// or second account required. This mirrors codemie-code's own
// HeaderInjectionPlugin, which sends this exact header set on every
// request its local proxy forwards.
//
// Registers two providers, both backed by the same credentials:
// codemie – all models, billed to the plain Web/Platform bucket.
// Non-Claude via OpenAI Chat Completions ({apiUrl}/v1),
// Claude via native Anthropic Messages ({apiUrl}/v1/messages).
// codemie-cli – identical models/routing/credentials, billed to the "(cli)"
// bucket via X-CodeMie-Client/X-CodeMie-CLI/X-CodeMie-Project
// (+ valid-UUID Session-ID/Request-ID) headers.
//
// Orphaned spend if X-CodeMie-Project is missing/unresolvable (e.g. the
// /v1/user lookup fails at startup): CONFIRMED that such requests still
// spend real money — they increment the account-wide
// `GET /v1/analytics/summaries` totals (`total_money_spent`, `cli_cost`) —
// but do NOT appear in ANY `/v1/analytics/budget_usage` row (not plain, not
// "(cli)", not "(premium)"). This is why `/codemie-usage` also queries the
// faster `cli-summary` endpoint: as a cross-check for spend that budget_usage
// can't attribute to a project. Whether an enforced $ cap still applies to
// this unattributed spend is UNCONFIRMED — deliberately exhausting a budget
// just to observe the error response was judged too risky to test against a
// real account. Do not treat this as a documented/supported way to dodge
// budget limits.
import { existsSync, readFileSync, writeFileSync, chmodSync, mkdirSync } from "node:fs";
import { createServer } from "node:http";
import { execFile } from "node:child_process";
import { homedir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
import { Image, Markdown } from "@earendil-works/pi-tui";
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
import { openAICompletionsApi } from "@earendil-works/pi-ai/compat";
// Convert an absolute path to a clickable Markdown link. The TUI renders
// `[label](url)` as an OSC 8 hyperlink, so the saved file opens in one click.
function fileLink(p, label = p) {
return `[${label}](${pathToFileURL(String(p)).href})`;
}
const AUTH_FILE = join(homedir(), ".pi", "agent", "auth.json");
const COOKIE_FILE = join(homedir(), ".pi", "agent", "codemie-cookie.txt");
// Both provider ids share one OAuth session/credential set (see the
// "Billing channel" note above) — kept in sync in ~/.pi/agent/auth.json.
const PROVIDER_IDS = ["codemie", "codemie-cli"];
const LOGIN_TIMEOUT_MS = 120_000;
// OAuth SSO returns an oauth2-proxy session cookie, not a refreshable access
// token. Re-check that cookie periodically instead of treating the JWT (when
// present) as the authoritative credential lifetime.
const COOKIE_SESSION_RECHECK_MS = 15 * 60 * 1000;
// Optional standard OAuth refresh support. CodeMie's current oauth2-proxy flow
// normally does not return a refresh token, but keep this configurable for
// instances that expose a token endpoint.
const CODEMIE_SSO_REFRESH_URL_ENV = "CODEMIE_SSO_REFRESH_URL";
// Default CodeMie instance — override with CODEMIE_BASE_URL.
const DEFAULT_CODEMIE_URL = "https://codemie.lab.epam.com";
// Bound every CodeMie discovery/lookup request so a slow or hung endpoint can
// never stall the (now background) model discovery indefinitely.
const CODIEMIE_FETCH_TIMEOUT_MS = 8_000;
// Merge an optional external abort signal with a hard per-call timeout.
const CODIEME_FETCH_TIMEOUT_MS = 8_000;
// Align the /v1/analytics/summaries window with the current billing period
// (budget_usage resets monthly) so the orphaned-spend gap isn't inflated by a
// window mismatch. Falls back to the endpoint's default window if unsupported.
const SUMMARIES_TIME_PERIOD = "current_month";
// Debug/verification only: when "1", codemie-cli omits X-CodeMie-Project,
// reproducing the "orphaned spend" scenario on demand (no failed /v1/user
// lookup needed). Off by default.
const FORCE_NO_PROJECT = process.env.CODEMIE_FORCE_NO_PROJECT === "1";
function withTimeout(signal, ms) {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), ms);
const clear = () => clearTimeout(timer);
ac.signal.addEventListener("abort", clear, { once: true });
if (signal) {
if (signal.aborted) ac.abort();
else signal.addEventListener("abort", () => ac.abort(), { once: true });
}
return ac.signal;
}
// Persisted model-discovery cache so an offline/flaky start still registers the
// last-known-good catalog (not just the hardcoded seed list). Keyed by API base
// URL because CodeMie models are instance-specific (CODEMIE_BASE_URL / stored
// SSO session).
const CACHE_DIR = join(homedir(), ".pi", "cache");
const CACHE_FILE = join(CACHE_DIR, "codemie-models.json");
const GENERATED_IMAGE_DIR = join(CACHE_DIR, "codemie-generated-images");
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24h
function loadCache(apiUrl) {
try {
if (!existsSync(CACHE_FILE)) return null;
const data = JSON.parse(readFileSync(CACHE_FILE, "utf-8"));
const entry = data?.urls?.[apiUrl];
if (!entry || typeof entry.timestamp !== "number") return null;
if (Date.now() - entry.timestamp > CACHE_TTL) return null;
return entry; // { models, cliProject }
} catch {
return null;
}
}
function saveCache(apiUrl, models, cliProject) {
try {
let data = {};
if (existsSync(CACHE_FILE)) {
try { data = JSON.parse(readFileSync(CACHE_FILE, "utf-8")); } catch { /* ignore */ }
}
data.urls = data.urls ?? {};
data.urls[apiUrl] = { timestamp: Date.now(), models, cliProject };
if (!existsSync(CACHE_DIR)) mkdirSync(CACHE_DIR, { recursive: true });
writeFileSync(CACHE_FILE, JSON.stringify(data));
} catch {
// best-effort; the cache is an optimization, never required
}
}
/**
* Normalize a CodeMie URL to the API base, exactly like codemie-code's
* `ensureApiBase` (src/providers/core/codemie-auth-helpers.ts): all backend
* routes live under /code-assistant-api, so "https://host" becomes
* "https://host/code-assistant-api".
*/
function ensureApiBase(rawUrl) {
const base = rawUrl.replace(/\/+$/, "");
return /\/code-assistant-api(\/|$)/i.test(base)
? base
: `${base}/code-assistant-api`;
}
/** Inverse of ensureApiBase: recover the frontend URL from an API base. */
function frontendFromApiBase(apiBase) {
return apiBase.replace(/\/+$/, "").replace(/\/code-assistant-api\/?$/i, "");
}
// ---------------------------------------------------------------------------
// Model metadata (mirrors codemie-code's src/agents/plugins/pi/pi.models.ts)
// ---------------------------------------------------------------------------
const RESPONSES_API_PATTERNS = [
/^gpt-5-2-/,
/^gpt-5\.2-/,
/^gpt-5-1-codex/,
/^gpt-5\.1-codex/,
/^gpt-5-3-codex/,
/^gpt-5\.3-codex/,
/^gpt-5-4-/,
/^gpt-5\.4-/,
/^gpt-5-5-/,
/^gpt-5\.5-/,
/^gpt-5-6-/,
/^gpt-5\.6-/,
];
function detectLimits(id) {
if (id.startsWith("claude")) return { contextWindow: 200000, maxTokens: 64000 };
if (id.startsWith("gemini")) return { contextWindow: 1048576, maxTokens: 65536 };
if (id.startsWith("gpt-4.1")) return { contextWindow: 1048576, maxTokens: 32768 };
if (/^gpt-5\.[56]-/.test(id) || /^gpt-5-[56]-/.test(id)) return { contextWindow: 1050000, maxTokens: 128000 };
if (id.startsWith("gpt-5")) return { contextWindow: 400000, maxTokens: 128000 };
if (/^o[134]-/.test(id) || id === "o1") return { contextWindow: 200000, maxTokens: 100000 };
if (id.startsWith("qwen") || id.startsWith("moonshotai") || id.startsWith("kimi")) {
return { contextWindow: 262144, maxTokens: 131072 };
}
if (id.startsWith("deepseek")) return { contextWindow: 65536, maxTokens: 65536 };
return { contextWindow: 128000, maxTokens: 4096 };
}
function isReasoningModel(id) {
return (
id.startsWith("claude") ||
id.startsWith("gemini") ||
id.startsWith("gpt-5") ||
/^o[134]-/.test(id) ||
id === "o1" ||
id.startsWith("deepseek") ||
id.startsWith("moonshotai") ||
id.startsWith("kimi")
);
}
function isValidRate(value) {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}
// CodeMie reports cost per token; Pi expects it per million tokens.
function rate(perToken) {
if (!isValidRate(perToken)) return 0;
const perMillion = perToken * 1_000_000;
return isValidRate(perMillion) ? perMillion : 0;
}
function convertLlmModel(model) {
const id = model.deployment_name || model.base_name || model.label;
const entry = {
id,
name: model.label || id,
reasoning: false,
input: model.multimodal ? ["text", "image"] : ["text"],
cost: {
input: rate(model.cost?.input),
output: rate(model.cost?.output),
cacheRead: rate(model.cost?.cache_read_input_token_cost),
cacheWrite: rate(model.cost?.cache_creation_input_token_cost),
},
...detectLimits(id),
};
// Newer GPT-5.x/Codex models speak the Responses API instead of Chat Completions.
if (RESPONSES_API_PATTERNS.some((pattern) => pattern.test(id))) {
entry.api = "openai-responses";
}
if (isReasoningModel(id)) {
entry.reasoning = true;
entry.thinkingLevelMap = {
off: null,
// The CodeMie gateway only accepts low/medium/high/xhigh/max — there
// is no "minimal" variant (400 "unknown variant `minimal`").
minimal: "low",
low: "low",
medium: "medium",
high: "high",
xhigh: "xhigh",
max: "max",
};
}
// Adaptive-thinking Claude models require thinking.type: "adaptive".
if (
id.startsWith("claude-sonnet-4-6") ||
id.startsWith("claude-sonnet-5") ||
/^claude-opus-4-[6-8]/.test(id) ||
id.startsWith("claude-opus-5")
) {
entry.compat = { forceAdaptiveThinking: true };
}
// Surface the deployment's real capabilities so Pi (and /codemie-capabilities)
// reflects what CodeMie actually reports. The live `GET /v1/llm_models`
// schema (confirmed against the official codemie-code client + a live
// probe of this account) exposes per model:
// multimodal -> vision (image INPUT)
// supports_image_generation -> image *generation* (a few models only)
// features.tools -> function/tool calling (`features` is an
// OBJECT, not an array: {tools, streaming,
// parallel_tool_calls, ...})
// There is NO video/audio-generation field in CodeMie's schema, so those
// capability flags stay false. (Image generation is flagged here, but this
// extension does not yet route generation requests to a CodeMie image
// endpoint — that endpoint is not yet verified.)
const cmFeatures = model.features ?? {};
entry.capabilities = {
tools: cmFeatures.tools === true,
vision: !!model.multimodal,
image: model.supports_image_generation === true,
video: false,
audio: false,
reasoning: entry.reasoning,
};
return entry;
}
// ---------------------------------------------------------------------------
// SSO login flow (mirrors codemie-code's src/providers/plugins/sso/sso.auth.ts)
// ---------------------------------------------------------------------------
function decodeJwtExp(jwt) {
try {
const parts = jwt.split(".");
if (parts.length !== 3) return undefined;
const payload = JSON.parse(Buffer.from(parts[1], "base64").toString("utf8"));
return typeof payload.exp === "number" ? payload.exp * 1000 : undefined;
} catch {
return undefined;
}
}
function openBrowser(url) {
const cmd =
process.platform === "darwin"
? "open"
: process.platform === "win32"
? "cmd"
: "xdg-open";
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
try {
execFile(cmd, args, () => {});
} catch {
// Browser launch is best-effort; the URL is printed either way.
}
}
/**
* Start a local callback server, hand the login URL to `onAuth`, and resolve
* with the decoded `{ cookies }` payload once the browser calls back.
*/
function waitForSsoCallback(codeMieUrl, onAuth) {
return new Promise((resolve, reject) => {
let timeoutHandle;
let settled = false;
const finish = (fn, value) => {
if (settled) return;
settled = true;
clearTimeout(timeoutHandle);
server.close();
fn(value);
};
const server = createServer((req, res) => {
try {
const url = new URL(req.url ?? "/", "http://localhost");
const raw =
url.searchParams.get("token") ||
url.searchParams.get("auth") ||
url.searchParams.get("data");
if (!raw) throw new Error("Missing token parameter in OAuth callback");
const token = JSON.parse(Buffer.from(raw, "base64").toString("ascii"));
if (!token.cookies) throw new Error("Token missing cookies field");
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(
"
CodeMie" +
"" +
"✅ Authentication Successful
" +
"Authentication complete. You can close this tab.
"
);
finish(resolve, token);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
res.writeHead(400, { "Content-Type": "text/plain" });
res.end(`CodeMie authentication failed: ${message}`);
finish(reject, error);
}
});
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
const loginUrl = `${codeMieUrl.replace(/\/+$/, "")}/v1/auth/login/${port}`;
console.error(`[codemie] Opening browser for SSO login...\n ${loginUrl}`);
if (onAuth) onAuth({ url: loginUrl });
else openBrowser(loginUrl);
});
timeoutHandle = setTimeout(() => {
finish(reject, new Error(`SSO login timed out after ${LOGIN_TIMEOUT_MS / 1000}s`));
}, LOGIN_TIMEOUT_MS);
});
}
/** Resolve the real API base URL from /config.js (VITE_API_URL), like the CLI does. */
async function resolveApiUrl(codeMieUrl, cookieString) {
const apiBase = codeMieUrl.replace(/\/+$/, "");
try {
const res = await fetch(`${apiBase}/config.js`, {
headers: cookieString ? { Cookie: cookieString } : {},
redirect: "follow",
});
if (res.ok) {
const match = /VITE_API_URL:\s*"([^"]+)"/.exec(await res.text());
if (match?.[1]) return match[1].replace(/\/+$/, "");
}
} catch {
// Optional step — fall back to the configured base URL.
}
return apiBase;
}
/** Parse the state carried in pi's OAuth `refresh` field. */
function credentialState(credential) {
try {
const state = JSON.parse(credential?.refresh ?? "{}");
return state && typeof state === "object" ? state : {};
} catch {
return {};
}
}
function cookieStringFromCookies(cookies) {
return Object.entries(cookies ?? {})
.filter(([, value]) => value != null)
.map(([key, value]) => `${key}=${value}`)
.join(";");
}
function cookieStringFromCredential(credential) {
return cookieStringFromCookies(credentialState(credential).cookies);
}
function hasSessionCookie(credential) {
const cookies = credentialState(credential).cookies ?? {};
return typeof cookies._oauth2_proxy === "string";
}
function jwtExpiryFromCredential(credential) {
const state = credentialState(credential);
const cookieJwt = state.cookies?.codemie_access_token;
return typeof cookieJwt === "string"
? decodeJwtExp(cookieJwt)
: decodeJwtExp(credential?.access ?? "");
}
function isAccessTokenExpired(credential) {
const expires = jwtExpiryFromCredential(credential);
return typeof expires === "number" && Date.now() >= expires - 5 * 60 * 1000;
}
/**
* Create one canonical credential object for both providers. A cookie is the
* real gateway credential, so a JWT expiry must not force a browser login while
* the cookie still passes an authenticated request.
*/
function createOAuthCredential({ cookies, apiUrl, access, expires, state = {} }) {
const nextCookies = { ...(cookies ?? {}) };
const nextState = { ...state, cookies: nextCookies, apiUrl };
const cookieString = cookieStringFromCookies(nextCookies);
const nextAccess = access ?? nextCookies.codemie_access_token ?? cookieString;
const hasCookie = typeof nextCookies._oauth2_proxy === "string";
const jwtExpires = typeof nextCookies.codemie_access_token === "string"
? decodeJwtExp(nextCookies.codemie_access_token)
: decodeJwtExp(nextAccess ?? "");
const explicitExpires = typeof expires === "number" && Number.isFinite(expires)
? expires
: undefined;
const nextExpires = hasCookie
? Math.min(
explicitExpires && explicitExpires > Date.now()
? explicitExpires
: Date.now() + COOKIE_SESSION_RECHECK_MS,
Date.now() + COOKIE_SESSION_RECHECK_MS
)
: explicitExpires ?? jwtExpires ?? Date.now() + 24 * 60 * 60 * 1000;
return {
refresh: JSON.stringify(nextState),
access: nextAccess,
expires: nextExpires,
apiUrl,
};
}
/** Choose the freshest copy when pi has persisted the two provider ids apart. */
function pickLatestOauth(...credentials) {
return credentials
.flat()
.filter((credential) => credential?.access && credential?.refresh)
.sort((a, b) => {
const expiresA = typeof a.expires === "number" ? a.expires : 0;
const expiresB = typeof b.expires === "number" ? b.expires : 0;
return expiresB - expiresA;
})[0];
}
/** Full interactive SSO login → pi OAuthCredentials-shaped object. */
async function performLogin(codeMieUrl, onAuth) {
const token = await waitForSsoCallback(codeMieUrl, onAuth);
const cookies = token.cookies ?? {};
const cookieString = cookieStringFromCookies(cookies);
const apiUrl = await resolveApiUrl(codeMieUrl, cookieString);
const tokenSet = token.tokens && typeof token.tokens === "object" ? token.tokens : token;
const refreshToken = tokenSet.refresh_token ?? tokenSet.refreshToken;
const refreshEndpoint =
tokenSet.refresh_endpoint ?? tokenSet.refreshEndpoint ?? tokenSet.token_endpoint ?? tokenSet.tokenEndpoint;
const state = {
...(refreshToken ? { refreshToken } : {}),
...(refreshEndpoint ? { refreshEndpoint } : {}),
};
return createOAuthCredential({
cookies,
apiUrl,
access: tokenSet.access_token ?? tokenSet.accessToken ?? cookies.codemie_access_token,
expires: typeof tokenSet.expires_in === "number"
? Date.now() + tokenSet.expires_in * 1000
: undefined,
state,
});
}
// ---------------------------------------------------------------------------
// Credential storage (same file pi uses: ~/.pi/agent/auth.json)
// ---------------------------------------------------------------------------
function readStoredOauth() {
try {
if (!existsSync(AUTH_FILE)) return undefined;
const parsed = JSON.parse(readFileSync(AUTH_FILE, "utf8"));
const candidates = PROVIDER_IDS.map((id) => {
const cred = parsed[id];
if (cred?.type !== "oauth" || !cred.access) return undefined;
let apiUrl;
try {
apiUrl = JSON.parse(cred.refresh ?? "{}").apiUrl;
} catch {}
return { ...cred, apiUrl };
});
return pickLatestOauth(candidates);
} catch {
// Corrupted/unreadable auth file — treat as no stored credentials.
return undefined;
}
}
function writeStoredOauth(credential) {
try {
const existing = existsSync(AUTH_FILE)
? JSON.parse(readFileSync(AUTH_FILE, "utf8"))
: {};
const shared = {
type: "oauth",
refresh: credential.refresh,
access: credential.access,
expires: credential.expires,
};
// These providers deliberately use one account/session. Always write the
// exact same record to both ids; startup also selects the newest record to
// recover from pi writing one provider independently.
for (const id of PROVIDER_IDS) existing[id] = { ...shared };
writeFileSync(AUTH_FILE, JSON.stringify(existing, null, 2), { mode: 0o600 });
chmodSync(AUTH_FILE, 0o600);
} catch (error) {
console.error(
`[codemie] Could not persist credentials to ${AUTH_FILE}: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
/**
* Cookie-backed credentials use `expires` only as a probe deadline; a JWT
* expiry alone must not trigger interactive login. Without a cookie, the
* access-token expiry is authoritative.
*/
function isRefreshDue(credential) {
if (hasSessionCookie(credential)) {
return !credential?.expires || Date.now() >= credential.expires - 5 * 60 * 1000;
}
return !credential?.expires || isAccessTokenExpired(credential) || Date.now() >= credential.expires - 5 * 60 * 1000;
}
function abortIfNeeded(signal) {
if (signal?.aborted) {
throw signal.reason ?? new Error("CodeMie authentication refresh aborted");
}
}
function setCookiesFromResponse(response) {
const headers = response.headers;
// getSetCookie() is available in Node's fetch implementation but is not
// present in every Headers type pi may compile against.
const getSetCookie = (headers as any).getSetCookie;
const values = typeof getSetCookie === "function"
? getSetCookie.call(headers)
: [headers.get("set-cookie")].filter(Boolean);
const cookies = {};
for (const value of values) {
// Only auth cookies belong in the credential payload. Cookie attributes
// (Path, Secure, Max-Age, ...) are deliberately not sent back as cookies.
const match = /(?:^|,\s*)(_oauth2_proxy|codemie_access_token)=([^;,]*)/.exec(value);
if (match) cookies[match[1]] = match[2];
}
return cookies;
}
function credentialWithResponseCookies(credential, response) {
const responseCookies = setCookiesFromResponse(response);
if (Object.keys(responseCookies).length === 0) return credential;
const state = credentialState(credential);
return createOAuthCredential({
cookies: { ...(state.cookies ?? {}), ...responseCookies },
apiUrl: state.apiUrl ?? credential.apiUrl,
access: credential.access,
state,
});
}
/**
* Check the actual gateway cookie independently of the JWT expiry. A 401/302
* means the browser session is gone; a network/5xx result is inconclusive and
* must not force an unnecessary browser login.
*/
async function probeCookieSession(apiUrl, credential, signal) {
const cookieString = cookieStringFromCredential(credential);
if (!cookieString || !hasSessionCookie(credential)) return { status: "missing" };
try {
abortIfNeeded(signal);
const response = await fetch(`${apiUrl.replace(/\/+$/, "")}/v1/user`, {
headers: { Cookie: cookieString },
redirect: "manual",
signal: withTimeout(signal, CODIEME_FETCH_TIMEOUT_MS),
});
const updated = credentialWithResponseCookies(credential, response);
if (response.ok) return { status: "valid", credential: updated };
if (response.status === 401 || response.status === 403 || (response.status >= 300 && response.status < 400)) {
return { status: "invalid", credential: updated };
}
return { status: "unknown", credential: updated };
} catch (error) {
abortIfNeeded(signal);
return { status: "unknown", credential };
}
}
function refreshInfoFromCredential(credential) {
const state = credentialState(credential);
const refreshToken = state.refreshToken ?? state.refresh_token;
const endpoint =
process.env[CODEMIE_SSO_REFRESH_URL_ENV] ??
state.refreshEndpoint ??
state.refresh_endpoint ??
state.tokenEndpoint ??
state.token_endpoint;
return { state, refreshToken, endpoint };
}
/**
* Use a real OAuth refresh-token endpoint when one was supplied by the SSO
* callback or explicitly configured. The current CodeMie oauth2-proxy flow
* generally supplies neither, in which case this returns undefined and the
* cookie probe below decides whether a browser login is actually needed.
*/
async function refreshWithEndpoint(baseUrl, credential, signal) {
const { state, refreshToken, endpoint } = refreshInfoFromCredential(credential);
if (!endpoint) return undefined;
const refreshUrl = new URL(endpoint, `${baseUrl.replace(/\/+$/, "")}/`).href;
try {
abortIfNeeded(signal);
const response = await fetch(refreshUrl, {
method: "POST",
headers: {
...(refreshToken ? { "Content-Type": "application/x-www-form-urlencoded" } : {}),
...(cookieStringFromCredential(credential)
? { Cookie: cookieStringFromCredential(credential) }
: {}),
},
...(refreshToken
? {
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
}),
}
: {}),
redirect: "manual",
signal: withTimeout(signal, CODIEME_FETCH_TIMEOUT_MS),
});
if (!response.ok) return undefined;
let payload = {};
try {
payload = await response.json();
} catch {
// Some gateways rotate only the session cookie and return an empty body.
}
const tokenSet = payload.tokens && typeof payload.tokens === "object"
? payload.tokens
: payload;
const responseCookies = setCookiesFromResponse(response);
const cookies = { ...(state.cookies ?? {}), ...responseCookies, ...(tokenSet.cookies ?? {}) };
const nextRefreshToken = tokenSet.refresh_token ?? tokenSet.refreshToken ?? refreshToken;
const nextEndpoint =
tokenSet.refresh_endpoint ?? tokenSet.refreshEndpoint ?? tokenSet.token_endpoint ?? endpoint;
const nextState = {
...state,
...(nextRefreshToken ? { refreshToken: nextRefreshToken } : {}),
...(nextEndpoint ? { refreshEndpoint: nextEndpoint } : {}),
};
const access = tokenSet.access_token ?? tokenSet.accessToken ?? cookies.codemie_access_token ?? credential.access;
const expires = typeof tokenSet.expires_in === "number"
? Date.now() + tokenSet.expires_in * 1000
: typeof tokenSet.expires_at === "number"
? tokenSet.expires_at
: undefined;
return createOAuthCredential({
cookies,
apiUrl: state.apiUrl ?? credential.apiUrl,
access,
expires,
state: nextState,
});
} catch (error) {
abortIfNeeded(signal);
return undefined;
}
}
/**
* Persist the OAuth credentials AND export the raw session cookie.
* The gateway authenticates API calls with the _oauth2_proxy session cookie —
* a Bearer JWT is rejected (302 to the SSO login page). We expose it to pi's
* provider config via an environment variable (`Cookie: $CODEMIE_SESSION_COOKIE`):
* unlike `!command` values, this needs no shell, which matters on Windows where
* neither `cat` nor cmd built-ins like `type` are reliably spawnable.
*/
function persistSession(credential) {
writeStoredOauth(credential);
try {
const cookieString = cookieStringFromCredential(credential);
process.env.CODEMIE_SESSION_COOKIE = cookieString;
writeFileSync(COOKIE_FILE, cookieString, { mode: 0o600 });
chmodSync(COOKIE_FILE, 0o600);
} catch (error) {
console.error(
`[codemie] Could not write cookie file ${COOKIE_FILE}: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
// ---------------------------------------------------------------------------
// Model discovery
// ---------------------------------------------------------------------------
async function fetchCodeMieModels(apiUrl, { bearer, cookieString }) {
const headers = {};
if (cookieString) headers["Cookie"] = cookieString;
else if (bearer) headers["Authorization"] = `Bearer ${bearer}`;
const res = await fetch(`${apiUrl.replace(/\/+$/, "")}/v1/llm_models?include_all=true`, {
headers,
redirect: "follow",
signal: withTimeout(undefined, CODIEME_FETCH_TIMEOUT_MS),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status} ${res.statusText}`);
}
const models = await res.json();
if (!Array.isArray(models)) {
throw new Error("unexpected response shape (expected array)");
}
return models
.filter((m) => m && m.enabled !== false)
.map(convertLlmModel)
.filter((m) => m && m.id);
}
/**
* Resolve the CodeMie project id for the `X-CodeMie-Project` header (see the
* "Billing channel" note above — this is the header that actually decides
* bucket attribution). Mirrors codemie-code's `fetchCodeMieUserInfo`
* (`GET {apiUrl}/v1/user`): `username`/`email` is the account's email, which
* is also the LiteLLM project name (``, ` (cli)`, ...).
*/
async function fetchCodeMieProject(apiUrl, { bearer, cookieString }) {
const headers = {};
if (cookieString) headers["Cookie"] = cookieString;
else if (bearer) headers["Authorization"] = `Bearer ${bearer}`;
const res = await fetch(`${apiUrl.replace(/\/+$/, "")}/v1/user`, {
headers,
redirect: "follow",
signal: withTimeout(undefined, CODIEME_FETCH_TIMEOUT_MS),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status} ${res.statusText}`);
}
const user = await res.json();
const project = user?.username || user?.email;
if (!project) {
throw new Error("user info response missing username/email");
}
return project;
}
/**
* Claude models are flagged to speak the Anthropic Messages protocol at the
* API root (preserving native thinking/caching), everything else uses
* OpenAI Chat Completions under /v1. Shared by both the `codemie` and
* `codemie-cli` providers.
*/
function routeModels(entries, apiUrl) {
return entries.map((entry) => {
if (!entry.id.startsWith("claude")) {
return { ...entry, compat: { ...entry.compat, supportsReasoningEffort: true } };
}
return {
...entry,
api: "anthropic-messages",
baseUrl: apiUrl, // Anthropic endpoint lives at the API root, not /v1
};
});
}
/** Seed model list used when no session/model-discovery is available yet. */
function seedModels() {
const SEED_MODELS = [
// GPT
"gpt-4.1",
"gpt-5-2025-08-07",
"gpt-5-mini-2025-08-07",
"gpt-5-nano-2025-08-07",
"gpt-5-1-codex-2025-11-13",
"gpt-5.4-2026-03-05",
"gpt-5.6-luna-2026-07-09",
// Reasoning
"o3-mini",
"o4-mini-2025-04-16",
// Claude
"claude-haiku-4-5-20251001",
"claude-sonnet-4-6",
"claude-sonnet-5",
"claude-opus-4-6-20260205",
"claude-opus-5",
// Gemini
"gemini-3-flash",
"gemini-3.1-pro",
"gemini-3.7-flash",
// Other
"deepseek-v4-pro",
"grok-4.6",
"moonshotai.kimi-k2.5",
];
return SEED_MODELS.map((id) => convertLlmModel({ deployment_name: id }));
}
// ---------------------------------------------------------------------------
// Extension entry point
// ---------------------------------------------------------------------------
export default function (pi) {
// Accept either https://host or https://host/code-assistant-api — normalize
// to the API base like the CodeMie CLI does. Defaults to the public EPAM lab
// instance when CODEMIE_BASE_URL is not set.
const codeMieUrl = ensureApiBase(
process.env.CODEMIE_BASE_URL || DEFAULT_CODEMIE_URL
);
// ---- Mode 1: explicit env-var auth (CI / service accounts) --------------
const jwt = process.env.CODEMIE_JWT_TOKEN || "";
const apiKeyEnv = process.env.CODEMIE_API_KEY || "";
const cookieEnv = process.env.CODEMIE_COOKIE || "";
let apiUrl = codeMieUrl;
let envAuth = null; // { headers } | { apiKey, authHeader? }
if (codeMieUrl && (jwt || apiKeyEnv || cookieEnv)) {
envAuth = cookieEnv
? { headers: { Cookie: cookieEnv } }
: { apiKey: jwt || apiKeyEnv };
}
// ---- Mode 2: OAuth SSO (login is lazy; refresh is cookie-aware) ---------
let oauthBlock = null;
// Both provider registrations point at this same in-memory credential. The
// promise also prevents codemie and codemie-cli from opening two browsers
// when they notice expiry at the same time.
let oauthCreds = null;
let oauthRefreshPromise = null;
let activeBaseUrl = codeMieUrl;
if (!envAuth) {
const makeOauthBlock = () => {
const resolveBaseUrl = async (callbacks) => {
if (!process.env.CODEMIE_BASE_URL && callbacks?.onPrompt) {
try {
const answer = await callbacks.onPrompt({
message: `CodeMie URL (Enter = ${DEFAULT_CODEMIE_URL}):`,
});
const trimmed = typeof answer === "string" ? answer.trim() : "";
if (trimmed) return ensureApiBase(trimmed);
} catch {
// Prompt unavailable/cancelled — fall back to the default.
}
}
return activeBaseUrl;
};
return {
name: "CodeMie (SSO)",
async login(callbacks) {
const baseUrl = await resolveBaseUrl(callbacks);
activeBaseUrl = baseUrl;
const cred = await performLogin(baseUrl, (info) =>
callbacks.onAuth(info)
);
persistSession(cred);
apiUrl = cred.apiUrl;
oauthCreds = cred;
return { refresh: cred.refresh, access: cred.access, expires: cred.expires };
},
async refreshToken(credentials, signal) {
abortIfNeeded(signal);
// pi can invoke this with the stale record belonging to one
// provider. Prefer the freshest shared copy from memory/disk.
const current = pickLatestOauth(oauthCreds, credentials, readStoredOauth()) ?? credentials;
if (!isRefreshDue(current)) {
oauthCreds = current;
persistSession(current);
return current;
}
if (oauthRefreshPromise) return oauthRefreshPromise;
oauthRefreshPromise = (async () => {
const latest = pickLatestOauth(oauthCreds, current, readStoredOauth()) ?? current;
if (!isRefreshDue(latest)) {
oauthCreds = latest;
persistSession(latest);
return latest;
}
// First use a real refresh-token endpoint, when CodeMie (or a
// configured deployment) supplied one. This is silent and does
// not involve the browser.
const refreshed = await refreshWithEndpoint(activeBaseUrl, latest, signal);
if (refreshed) {
persistSession(refreshed);
apiUrl = refreshed.apiUrl ?? apiUrl;
oauthCreds = refreshed;
return refreshed;
}
if (hasSessionCookie(latest)) {
if (isAccessTokenExpired(latest)) {
console.error("[codemie] JWT expired; checking the SSO cookie before asking for login...");
}
const probe = await probeCookieSession(latest.apiUrl ?? apiUrl, latest, signal);
if (probe.status === "valid" || probe.status === "unknown") {
// A valid cookie is the real auth state. If the probe was
// inconclusive (network/5xx), keep working and retry the
// probe later rather than opening a needless browser.
const checked = probe.credential ?? latest;
const state = credentialState(checked);
const extended = createOAuthCredential({
cookies: state.cookies ?? {},
apiUrl: state.apiUrl ?? checked.apiUrl ?? apiUrl,
access: checked.access,
state,
});
persistSession(extended);
apiUrl = extended.apiUrl ?? apiUrl;
oauthCreds = extended;
return extended;
}
// 401/403/302 means the cookie itself is invalid, regardless
// of whether its JWT-shaped companion has expired.
console.error("[codemie] SSO cookie is no longer valid — opening browser login...");
}
const cred = await performLogin(activeBaseUrl);
persistSession(cred);
apiUrl = cred.apiUrl;
oauthCreds = cred;
return cred;
})();
try {
return await oauthRefreshPromise;
} finally {
oauthRefreshPromise = null;
}
},
getApiKey(credentials) { return credentials.access; },
};
};
oauthBlock = makeOauthBlock();
// Reuse whatever session is already stored — never open a browser at
// startup. Login happens only when the user asks for it (`/login codemie`)
// or when an actual CodeMie request needs a refresh.
const stored = readStoredOauth();
if (stored) {
oauthCreds = stored;
if (stored.apiUrl) {
apiUrl = stored.apiUrl;
// Refreshes after a restart must reopen the browser on the SAME
// instance the stored session belongs to.
activeBaseUrl = ensureApiBase(frontendFromApiBase(stored.apiUrl));
}
persistSession(stored); // keep the cookie file in sync with auth.json
}
}
const authConfig = envAuth
? envAuth
: oauthBlock
? {
// The gateway authenticates with the _oauth2_proxy session cookie
// (Bearer JWTs get a 302 to the SSO login). persistSession() keeps
// this env var in sync on every login/refresh; env interpolation
// needs no shell, so it works identically on Windows and Unix.
oauth: oauthBlock,
headers: { Cookie: "$CODEMIE_SESSION_COOKIE" },
}
: undefined;
// ---- codemie-cli: SAME account/session, different billing channel -------
// (see README "Two billing channels" for the measurement that pinned down
// X-CodeMie-Project as the bucket-attribution header.)
const cliSessionId = randomUUID();
const cliRequestId = randomUUID();
const cliChannelHeaders = {
"X-CodeMie-Client": "codemie-pi",
"X-CodeMie-CLI": "codemie-cli/1.0.0",
"X-CodeMie-Session-ID": cliSessionId,
"X-CodeMie-Request-ID": cliRequestId,
};
// --- Image-generation echo -------------------------------------------------
// CodeMie has no dedicated /images endpoint — image generation rides the
// standard chat/completions path and the model returns the image inline in
// `message.images[]` (verified live). pi's built-in OpenAI adapter drops
// `message.images`, so for image-capable models we use a custom streamSimple
// that calls chat/completions (non-streaming), saves the images, and adds
// TUI-only Image entries while exposing the saved paths in the assistant text.
let resolvedCliProject;
function baseOutput(model, stopReason) {
return {
role: "assistant",
content: [],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason,
timestamp: Date.now(),
};
}
async function saveGeneratedImage(url) {
if (!url || typeof url !== "string") return null;
let mimeType = "image/png";
let data;
const dataUri = /^data:([^;,]+);base64,(.*)$/s.exec(url);
if (dataUri) {
mimeType = dataUri[1] || mimeType;
data = Buffer.from(dataUri[2], "base64");
} else {
const response = await fetch(url);
if (!response.ok) throw new Error(`Unable to download CodeMie image: HTTP ${response.status}`);
const contentType = response.headers.get("content-type");
if (contentType?.startsWith("image/")) mimeType = contentType.split(";", 1)[0];
data = Buffer.from(await response.arrayBuffer());
}
if (!data?.length) throw new Error("CodeMie returned an empty image");
if (!existsSync(GENERATED_IMAGE_DIR)) mkdirSync(GENERATED_IMAGE_DIR, { recursive: true });
const extension = mimeType.includes("jpeg") ? "jpg" : mimeType.includes("webp") ? "webp" : "png";
const path = join(GENERATED_IMAGE_DIR, `codemie-${Date.now()}-${randomUUID().slice(0, 8)}.${extension}`);
writeFileSync(path, data);
return { path, mimeType };
}
function textOf(m) {
if (typeof m?.content === "string") return m.content;
if (Array.isArray(m?.content)) {
return m.content.filter((p) => p?.type === "text").map((p) => p.text ?? "").join("\n");
}
return "";
}
function contentParts(m) {
if (typeof m?.content === "string") return m.content;
if (Array.isArray(m?.content)) {
const parts = [];
for (const p of m.content) {
if (p?.type === "text") parts.push({ type: "text", text: p.text ?? "" });
else if (p?.type === "image") {
const src = p.source;
const url = src?.type === "base64" ? `data:${src.mediaType || "image/png"};base64,${src.data}` : src?.url ?? src?.uri;
if (url) parts.push({ type: "image_url", image_url: { url } });
}
}
return parts;
}
if (Array.isArray(m?.images)) {
const parts = [{ type: "text", text: textOf(m) }];
for (const img of m.images) {
const url = typeof img === "string" ? img : img?.url ?? img?.image_url?.url ?? "";
if (url) parts.push({ type: "image_url", image_url: { url } });
}
return parts;
}
return textOf(m);
}
function toOpenAIMessages(context) {
const out = [];
const hasSystemPrompt = !!context?.systemPrompt;
if (hasSystemPrompt) out.push({ role: "system", content: context.systemPrompt });
const msgs = Array.isArray(context?.messages) ? context.messages : [];
for (const m of msgs) {
const role = m?.role;
if (role === "system") {
if (!hasSystemPrompt) out.push({ role: "system", content: textOf(m) });
continue;
}
if (role === "user") out.push({ role: "user", content: contentParts(m) });
else if (role === "assistant") out.push({ role: "assistant", content: textOf(m) });
else if (role === "tool") out.push({ role: "tool", content: textOf(m), tool_call_id: m?.toolCallId });
}
return out;
}
function streamCodeMieImage(model, context, options, baseUrl, getHeaders) {
const stream = createAssistantMessageEventStream();
const output = baseOutput(model, "pending");
(async () => {
try {
stream.push({ type: "start", partial: output });
const messages = toOpenAIMessages(context);
const body = { model: model.id, messages, stream: false };
if (model?.samplingParams?.temperature != null) body.temperature = model.samplingParams.temperature;
const headers = { "Content-Type": "application/json", ...getHeaders() };
const response = await fetch(`${baseUrl}/chat/completions`, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: options?.signal,
});
if (!response.ok) {
const txt = await response.text().catch(() => "");
throw new Error(`CodeMie image API HTTP ${response.status}: ${txt.slice(0, 400)}`);
}
const data = await response.json();
const choice = Array.isArray(data?.choices) ? data.choices[0] : undefined;
const msg = choice?.message ?? {};
const text = typeof msg.content === "string" ? msg.content : "";
const images = Array.isArray(msg.images) ? msg.images : [];
const savedImages = [];
for (const img of images) {
const url = img?.image_url?.url ?? img?.url ?? "";
const saved = await saveGeneratedImage(url);
if (saved) savedImages.push(saved);
}
const imageNotice = savedImages.map((image) => `Generated image saved to: ${fileLink(image.path)}`).join("\n");
const displayText = [text, imageNotice].filter(Boolean).join(text && imageNotice ? "\n\n" : "");
if (displayText) {
output.content.push({ type: "text", text: "" });
const ci = output.content.length - 1;
stream.push({ type: "text_start", contentIndex: ci, partial: output });
const block = output.content[ci];
block.text += displayText;
stream.push({ type: "text_delta", contentIndex: ci, delta: displayText, partial: output });
stream.push({ type: "text_end", contentIndex: ci, content: block.text, partial: output });
}
for (const image of savedImages) {
pi.appendEntry("codemie-generated-image", image);
}
if (savedImages.length === 0 && !text) {
throw new Error("CodeMie returned neither an image nor text");
}
output.stopReason = choice?.finish_reason ?? "stop";
stream.push({ type: "done", reason: output.stopReason, message: output });
stream.end();
} catch (error) {
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = error instanceof Error ? error.message : String(error);
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
})();
return stream;
}
function streamCodeMie(model, context, options) {
if (model?.capabilities?.image) {
const getHeaders =
model.provider === "codemie-cli"
? () => ({
...buildAuthHeaders(),
...cliChannelHeaders,
...(resolvedCliProject && !FORCE_NO_PROJECT ? { "X-CodeMie-Project": resolvedCliProject } : {}),
})
: buildAuthHeaders;
return streamCodeMieImage(model, context, options, `${apiUrl}/v1`, getHeaders);
}
return openAICompletionsApi().streamSimple(model, context, options);
}
// Register both providers from a model list (+ optional resolved cli project).
// Called once now with seed models, and again in the background once live
// model discovery completes — pi applies post-load registrations immediately,
// so this hot-swaps the catalog without a /reload.
function registerProviders(modelEntries, cliProject) {
const routed = routeModels(modelEntries, apiUrl);
if (routed.length === 0) return;
pi.registerProvider("codemie", {
name: "CodeMie",
baseUrl: `${apiUrl}/v1`,
...authConfig,
api: "openai-completions",
streamSimple: streamCodeMie,
models: routed,
});
// Clone (deep enough: top-level model + nested cost/compat/thinkingLevelMap
// objects) rather than reusing `routed`/nested objects by reference across
// two registerProvider() calls, in case pi/pi-ai attaches per-registration
// state (e.g. a resolved `provider` id) onto the model objects it's given.
const cliModels = routed.map((m) => ({
...m,
cost: { ...m.cost },
...(m.compat ? { compat: { ...m.compat } } : {}),
...(m.thinkingLevelMap ? { thinkingLevelMap: { ...m.thinkingLevelMap } } : {}),
}));
pi.registerProvider("codemie-cli", {
name: "CodeMie (CLI billing channel)",
baseUrl: `${apiUrl}/v1`,
...authConfig,
headers: {
...(authConfig?.headers ?? {}),
...cliChannelHeaders,
...(cliProject && !FORCE_NO_PROJECT ? { "X-CodeMie-Project": cliProject } : {}),
},
api: "openai-completions",
streamSimple: streamCodeMie,
models: cliModels,
});
}
// ---- Register immediately with cached (or seed) models (no network) -------
const cached = loadCache(apiUrl);
registerProviders(cached?.models ?? seedModels(), cached?.cliProject);
/** Headers for ad-hoc authenticated requests (usage/analytics), mirroring
* whichever auth mode is currently active. Re-read on every call so a
* post-login cookie refresh (persistSession) is picked up immediately. */
function buildAuthHeaders() {
if (envAuth?.headers) return { ...envAuth.headers };
if (envAuth?.apiKey) return { Authorization: `Bearer ${envAuth.apiKey}` };
const cookieString = process.env.CODEMIE_SESSION_COOKIE || "";
if (cookieString) return { Cookie: cookieString };
return {};
}
// ---- Commands & status bar (unchanged) ----------------------------------
registerPricesCommand(pi);
registerCapabilitiesCommand(pi);
pi.registerEntryRenderer?.("codemie-generated-image", (entry, _options, theme) => {
const image = entry.data ?? {};
// pi passes an entry-renderer `theme` that lacks `fallbackColor()`, which
// `Image.render` calls. Wrap it so inline previews render and never throw.
const imageTheme = theme && typeof theme.fallbackColor === "function"
? theme
: { fallbackColor: (s) => (theme && theme.fg ? theme.fg("toolOutput", s) : s) };
try {
const data = readFileSync(image.path).toString("base64");
return new Image(data, image.mimeType || "image/png", imageTheme, { maxWidthCells: 80, maxHeightCells: 30 });
} catch {
return new Markdown(`Generated image unavailable: ${fileLink(image.path ?? "unknown path")}`, 1, 0, getMarkdownTheme());
}
});
registerUsageCommand(pi, () => apiUrl, () => buildAuthHeaders());
// Both status widgets read the SAME account's budget_usage response (one
// account, multiple buckets) — they differ only in which row(s) they sum:
// "codemie" excludes the "(cli)" row (that's the OTHER provider's spend),
// "codemie-cli" shows ONLY the "(cli)" row.
registerUsageStatusBar(pi, "codemie", "codemie-usage-status", "💰", "web", () => apiUrl, () => buildAuthHeaders());
registerUsageStatusBar(pi, "codemie-cli", "codemie-cli-usage-status", "🖥️", "cli", () => apiUrl, () => buildAuthHeaders());
// ---- Background model discovery (non-blocking) --------------------------
// Live discovery + cli project resolution run after startup so pi is usable
// immediately. Failures are silent — the seed list already registered stays.
setTimeout(() => backgroundDiscover().catch(() => {}), 100);
async function backgroundDiscover() {
let bearer;
let cookieString = "";
if (envAuth) {
bearer = envAuth.apiKey;
cookieString = envAuth.headers?.Cookie ?? "";
} else if (oauthCreds) {
bearer = oauthCreds.access;
cookieString = cookieStringFromCredential(oauthCreds);
} else {
return; // no credentials → keep seed list
}
let entries;
try {
entries = await fetchCodeMieModels(apiUrl, { bearer, cookieString });
} catch (error) {
console.error(
`[codemie] Live model fetch failed (${
error instanceof Error ? error.message : String(error)
}). Keeping seed list.`
);
return;
}
if (!entries || entries.length === 0) return; // keep seed list
// Best-effort: resolve the account project for the (cli) billing bucket.
let cliProject;
try {
cliProject = await fetchCodeMieProject(apiUrl, { bearer, cookieString });
} catch {
// usage still billed for real but won't show in any /codemie-usage row
// (see summaries.total_money_spent) — acceptable, just less visible.
}
resolvedCliProject = cliProject;
registerProviders(entries, cliProject);
saveCache(apiUrl, entries, cliProject);
}
}
// ---------------------------------------------------------------------------
// /codemie-prices — cost-sortable pricing table for CodeMie models
// ---------------------------------------------------------------------------
const SORT_KEYS = { input: "input", output: "output", total: "total", context: "contextWindow" };
function fmtRate(n) {
if (!n) return "free";
return `$${n < 1 ? n.toFixed(3) : n.toFixed(2)}`;
}
function fmtSize(n) {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1000).toFixed(0)}K`;
return String(n);
}
function buildPricesMarkdown(models, sortKey, desc) {
const rows = models
.map((m) => ({
id: m.id,
input: m.cost?.input ?? 0,
output: m.cost?.output ?? 0,
cacheRead: m.cost?.cacheRead ?? 0,
cacheWrite: m.cost?.cacheWrite ?? 0,
total: (m.cost?.input ?? 0) + (m.cost?.output ?? 0),
contextWindow: m.contextWindow ?? 0,
maxTokens: m.maxTokens ?? 0,
}))
.sort((a, b) => (desc ? b[sortKey] - a[sortKey] : a[sortKey] - b[sortKey]));
const lines = [
`# CodeMie model prices (per 1M tokens, sorted by ${sortKey}${desc ? " desc" : " asc"})`,
"",
"| Model | Input | Output | Cache Read | Cache Write | Context | Max Out |",
"|---|---|---|---|---|---|---|",
...rows.map(
(r) =>
`| ${r.id} | ${fmtRate(r.input)} | ${fmtRate(r.output)} | ${fmtRate(r.cacheRead)} | ${fmtRate(
r.cacheWrite
)} | ${fmtSize(r.contextWindow)} | ${fmtSize(r.maxTokens)} |`
),
];
return lines.join("\n");
}
// `codemie` and `codemie-cli` register the exact same model list (only the
// billing-channel headers differ), so pricing is shown once from `codemie`.
function registerPricesCommand(pi) {
pi.registerCommand("codemie-prices", {
description:
"List CodeMie models sorted by price (input/output/total/context); e.g. /codemie-prices output desc",
getArgumentCompletions(prefix) {
const items = [
...Object.keys(SORT_KEYS).map((k) => ({ value: k, label: k })),
{ value: "asc", label: "asc" },
{ value: "desc", label: "desc" },
];
const filtered = items.filter((i) => i.value.startsWith(prefix));
return filtered.length > 0 ? filtered : null;
},
handler: async (args, ctx) => {
const tokens = (args || "").trim().split(/\s+/).filter(Boolean);
const sortArg = tokens.find((t) => t in SORT_KEYS) || "total";
const desc = tokens.includes("desc");
const sortKey = SORT_KEYS[sortArg];
const models = ctx.modelRegistry.getAvailable().filter((m) => m.provider === "codemie");
if (models.length === 0) {
notifyOrPrint(ctx, "No CodeMie models available (not authenticated yet?).");
return;
}
const markdown = buildPricesMarkdown(models, sortKey, desc);
if (ctx.mode === "tui") {
pi.appendEntry("codemie-prices", { markdown });
} else if (ctx.hasUI) {
ctx.ui.notify(markdown, "info");
} else {
// print/json modes: notify() is a no-op (ctx.hasUI === false) — write directly.
console.log(markdown);
}
},
});
pi.registerEntryRenderer?.("codemie-prices", (entry) => {
const data = entry.data;
const mdTheme = getMarkdownTheme();
return new Markdown(data.markdown, 1, 0, mdTheme);
});
}
// ---------------------------------------------------------------------------
// /codemie-capabilities — per-deployment capability table
// ---------------------------------------------------------------------------
function registerCapabilitiesCommand(pi) {
const flags = {
reasoning: "reasoning",
vision: "vision",
image: "image",
video: "video",
audio: "audio",
tools: "tools",
};
pi.registerCommand("codemie-capabilities", {
description:
"List CodeMie deployment capabilities (vision/image/video/audio/tools/reasoning); e.g. /codemie-capabilities image",
handler: async (args, ctx) => {
const tokens = (args || "").trim().split(/\s+/).filter(Boolean);
const filter = tokens.find((token) => token in flags);
// `codemie` and `codemie-cli` expose the same deployments; only their
// billing headers differ. Use the canonical provider here so each
// deployment appears once (as /codemie-prices already does).
const models = ctx.modelRegistry.getAvailable().filter((m) => m.provider === "codemie");
const rows = models
.map((model) => {
const caps = model.capabilities ?? {};
return {
id: model.id,
type: model.id.startsWith("claude") ? "claude" : "chat",
reasoning: caps.reasoning ? "✓" : "",
vision: caps.vision ? "✓" : "",
image: caps.image ? "✓" : "",
video: caps.video ? "✓" : "",
audio: caps.audio ? "✓" : "",
tools: caps.tools ? "✓" : "",
};
})
.filter((row) => !filter || row[flags[filter]] === "✓")
.sort((a, b) => (a.type === b.type ? a.id.localeCompare(b.id) : a.type.localeCompare(b.type)));
const markdown = [
`# CodeMie deployment capabilities${filter ? ` (filter: ${filter})` : ""}`,
"",
"| Model | Type | Reasoning | Vision | Image | Video | Audio | Tools |",
"|---|---|:---:|:---:|:---:|:---:|:---:|:---:|",
...rows.map(
(row) =>
`| ${row.id} | ${row.type} | ${row.reasoning || "—"} | ${row.vision || "—"} | ${row.image || "—"} | ${row.video || "—"} | ${row.audio || "—"} | ${row.tools || "—"} |`,
),
"",
"_Capabilities are read from each deployment's CodeMie model metadata (multimodal / type / capabilities / supported_features)._",
].join("\n");
if (ctx.mode === "tui") {
pi.appendEntry("codemie-capabilities", { markdown });
} else if (ctx.hasUI) {
ctx.ui.notify(markdown, "info");
} else {
console.log(markdown);
}
},
});
pi.registerEntryRenderer?.("codemie-capabilities", (entry) => {
const mdTheme = getMarkdownTheme();
return new Markdown(entry.data.markdown, 1, 0, mdTheme);
});
}
// ---------------------------------------------------------------------------
// /codemie-usage — account budget/quota usage
// (confirmed endpoint, captured from the CodeMie web UI's Network panel:
// GET {apiUrl}/v1/analytics/budget_usage)
// ---------------------------------------------------------------------------
async function fetchBudgetUsage(apiUrl, headers) {
const res = await fetch(`${apiUrl.replace(/\/+$/, "")}/v1/analytics/budget_usage`, {
headers,
redirect: "follow",
});
if (!res.ok) {
throw new Error(`HTTP ${res.status} ${res.statusText}`);
}
return res.json();
}
/**
* `budget_usage` lags real spend by ~5-10 min. The analytics *insights*
* dashboard (https://{host}/analytics?tab=insights) is backed by a
* different, much faster endpoint set — confirmed by timing: a token-count
* bump showed up within ~20-30s of a real request, vs. minutes for
* `budget_usage`. `cli-summary` reports CLI-proxy-channel traffic only
* (i.e. requests carrying `X-CodeMie-Client`, like `codemie-cli`'s) — it
* won't move for plain `codemie` usage. Used as a best-effort supplement to
* `/codemie-usage`, not a replacement (it has no per-bucket $ breakdown).
*/
async function fetchCliInsightsSummary(apiUrl, headers, timePeriod = "last_24_hours") {
const res = await fetch(
`${apiUrl.replace(/\/+$/, "")}/v1/analytics/cli-summary?time_period=${encodeURIComponent(timePeriod)}`,
{ headers, redirect: "follow" }
);
if (!res.ok) {
throw new Error(`HTTP ${res.status} ${res.statusText}`);
}
return res.json();
}
/**
* Account-wide spend totals (GET /v1/analytics/summaries) — the same numbers
* behind the analytics dashboard's "Total Money Spent" card. Used to surface
* "orphaned" spend: real money counted here but absent from every
* `budget_usage` row (see buildUsageMarkdown). Best-effort: its failure must
* not block the budget table above.
*/
async function fetchSummaries(apiUrl, headers, timePeriod = SUMMARIES_TIME_PERIOD) {
const base = `${apiUrl.replace(/\/+$/, "")}/v1/analytics/summaries`;
const url = timePeriod ? `${base}?time_period=${encodeURIComponent(timePeriod)}` : base;
const res = await fetch(url, { headers, redirect: "follow" });
if (!res.ok) {
// The param may be unsupported on some instances — retry without it so the
// orphaned-spend row still works off the default (last-30d) window.
if (timePeriod) {
const fallback = await fetch(base, { headers, redirect: "follow" });
if (fallback.ok) return fallback.json();
throw new Error(`HTTP ${fallback.status} ${fallback.statusText}`);
}
throw new Error(`HTTP ${res.status} ${res.statusText}`);
}
return res.json();
}
/** Flatten `{ data: { metrics: [{ id, value }, ...] } }` into `{ id: value }`. */
function summarizeCliInsights(payload) {
const metrics = payload?.data?.metrics;
if (!Array.isArray(metrics)) return undefined;
const byId = {};
for (const m of metrics) {
if (m && typeof m === "object" && typeof m.id === "string") byId[m.id] = m.value;
}
return byId;
}
/** Coerce a value to a finite number, tolerating string-encoded amounts
* (e.g. "27.89" or "$27.89" — CodeMie sometimes returns numbers as strings). */
function toFiniteNumber(v) {
if (typeof v === "number") return v;
if (typeof v === "string") {
const s = v.replace(/[^0-9.\-]/g, "");
if (s === "") return NaN;
const n = Number(s);
return Number.isFinite(n) ? n : NaN;
}
return NaN;
}
/** Best-effort JSON snapshot for diagnostics (never throws). */
function safeDump(value) {
try {
return JSON.stringify(value).slice(0, 500);
} catch {
return String(value);
}
}
/**
* `GET /v1/analytics/summaries` returns the SAME `{ data: { metrics: [...] } }`
* envelope as cli-summary: a flat list of `{ id, value, ... }` metric objects
* (NOT an object keyed by metric name). Pull `total_money_spent` by matching the
* metric `id`. Tolerates string-encoded `value`s.
*/
function extractTotalSpent(payload) {
const metrics = payload?.data?.metrics;
if (!Array.isArray(metrics)) return undefined;
for (const m of metrics) {
if (m && m.id === "total_money_spent") {
const n = toFiniteNumber(m.value);
return Number.isFinite(n) ? n : undefined;
}
}
return undefined;
}
/** notify() is a no-op outside TUI/RPC (ctx.hasUI === false in print/json) — fall
* back to stdout so print-mode/CI callers still see the message. */
function notifyOrPrint(ctx, message, level = "warning") {
if (ctx.hasUI) ctx.ui.notify(message, level);
else console.log(message);
}
function fmtMoney(n) {
return typeof n === "number" && Number.isFinite(n) ? `$${n.toFixed(2)}` : String(n ?? "");
}
function fmtPercent(n) {
return typeof n === "number" && Number.isFinite(n) ? `${n.toFixed(1)}%` : String(n ?? "");
}
function fmtTimestamp(iso) {
const t = Date.parse(iso);
if (Number.isNaN(t)) return String(iso ?? "");
return new Date(t).toISOString().replace("T", " ").replace(/\.\d+Z$/, " UTC");
}
/** Render an unknown JSON shape as markdown without assuming its schema:
* a flat array of objects becomes a table, a plain object becomes a
* key/value list, anything else is pretty-printed as a JSON code block. */
function buildGenericMarkdown(data) {
const lines = ["# CodeMie budget usage", ""];
if (Array.isArray(data) && data.length > 0 && data.every((row) => row && typeof row === "object")) {
const keys = Array.from(new Set(data.flatMap((row) => Object.keys(row))));
lines.push(`| ${keys.join(" | ")} |`);
lines.push(`|${keys.map(() => "---").join("|")}|`);
for (const row of data) {
lines.push(`| ${keys.map((k) => String(row[k] ?? "")).join(" | ")} |`);
}
return lines.join("\n");
}
if (data && typeof data === "object" && !Array.isArray(data)) {
lines.push("| Field | Value |", "|---|---|");
for (const [key, value] of Object.entries(data)) {
const rendered =
value && typeof value === "object" ? "```json\n" + JSON.stringify(value, null, 2) + "\n```" : String(value);
lines.push(`| ${key} | ${rendered} |`);
}
return lines.join("\n");
}
lines.push("```json", JSON.stringify(data, null, 2), "```");
return lines.join("\n");
}
/**
* `/v1/analytics/budget_usage` returns `{ data: { columns, rows, totals }, metadata }`
* (a generic "report table" shape). Render the known `rows` fields
* (project_name, current_spending, budget_limit, total%, budget_reset_at,
* time_until_reset) as a friendly table; fall back to the generic renderer
* for anything that doesn't match this exact shape.
*/
function fmtNumber(n) {
return typeof n === "number" && Number.isFinite(n) ? n.toLocaleString("en-US") : String(n ?? "");
}
/**
* Renders the fast `cli-summary` metrics (see fetchCliInsightsSummary) as a
* compact table, plus a link to the full insights dashboard. `insights` is
* the flattened `{ id: value }` map from summarizeCliInsights(), or
* undefined if that fetch failed/was skipped — in which case only the link
* is shown so /codemie-usage still degrades gracefully.
*/
function buildInsightsSection(insights, dashboardUrl) {
const lines = [
"",
"## CLI channel (fast, near real-time)",
"",
`_from \`/v1/analytics/cli-summary\` — updates within ~20-30s of a request, unlike the budget table above. Covers \`codemie-cli/*\` (and any other CLI/agent client) traffic only; not broken out by $ bucket._`,
];
if (insights) {
lines.push(
"",
"| Metric | Value |",
"|---|---|",
`| CLI cost (last 24h) | ${fmtMoney(insights.cli_cost)} |`,
`| Total tokens (last 24h) | ${fmtNumber(insights.total_tokens)} |`,
`| Sessions (last 24h) | ${fmtNumber(insights.unique_sessions)} |`
);
} else {
lines.push("", "_(could not fetch — see warning above; the dashboard link below still works.)_");
}
if (dashboardUrl) {
lines.push("", `Full breakdown (per-client, per-repo, sessions, tools): ${dashboardUrl}/analytics?tab=insights`);
}
return lines.join("\n");
}
function buildUsageMarkdown(payload, insights, dashboardUrl, summaryTotal, summaryNote) {
const rows = payload?.data?.rows;
if (!Array.isArray(rows) || rows.length === 0) {
return buildGenericMarkdown(payload) + "\n" + buildInsightsSection(insights, dashboardUrl);
}
// Account-wide total (GET /v1/analytics/summaries) minus what the budget
// table attributes to any project. The gap is "orphaned" spend — real money
// counted in the account total but in NO budget_usage row (usually because
// X-CodeMie-Project was missing). NOTE: budget_usage lags ~5-10 min while
// summaries updates in ~20-30s, so this gap ALSO includes recently-made spend
// that simply hasn't synced into the budget table yet — not only true orphans.
const budgetTotal = rows.reduce(
(s, r) => s + (typeof r.current_spending === "number" ? r.current_spending : 0),
0
);
const orphaned =
typeof summaryTotal === "number" ? Math.max(0, summaryTotal - budgetTotal) : undefined;
const showOrphaned = typeof orphaned === "number" && orphaned > 0.001;
const asOf = payload?.metadata?.data_as_of;
const lines = [
"# CodeMie budget usage",
"",
asOf ? `_as of ${fmtTimestamp(asOf)}_` : undefined,
"_(budget_usage lags real spend by ~5-10 min. Same account, three billing " +
"channels: the plain row is Web/Platform, \"(cli)\" is `codemie-cli/*` " +
"usage, \"(premium)\" is premium-model usage from either. The status bar " +
"shows the plain+premium total while a `codemie/*` model is active, and " +
"the \"(cli)\" total while a `codemie-cli/*` model is active.)_",
"",
"| Project | Spent | Budget Limit | Used % | Resets At | Time Until Reset |",
"|---|---|---|---|---|---|",
...rows.map((r) =>
`| ${r.project_name ?? ""} | ${fmtMoney(r.current_spending)} | ${fmtMoney(r.budget_limit)} | ${fmtPercent(
r.total
)} | ${fmtTimestamp(r.budget_reset_at)} | ${r.time_until_reset ?? ""} |`
),
...(showOrphaned
? [
`| **Orphaned spend** | ${fmtMoney(orphaned)} | — | — | — | — |`,
"",
"_**Orphaned spend** = account-wide total (`/v1/analytics/summaries` " +
"`total_money_spent`) − sum of every `budget_usage` row. It is real, " +
"attributed to no project/bucket — usually because `X-CodeMie-Project` " +
"was missing on those requests. It is **not free** and may still hit a " +
"parent API-key budget cap. (This gap also folds in timing/window " +
"differences: `summaries` covers roughly the last 30 days per its " +
"metadata while `budget_usage` is the current billing period, plus the " +
"~5-10 min `budget_usage` lag — so a non-zero value is not purely true " +
"orphans.)_",
]
: []),
...(summaryNote
? [
"",
`> Orphaned-spend check skipped: ${summaryNote}`,
]
: []),
].filter((l) => l !== undefined);
return lines.join("\n") + "\n" + buildInsightsSection(insights, dashboardUrl);
}
/**
* Registers `/codemie-usage`, showing the full budget_usage table (all
* buckets/rows: Web/Platform, "(cli)", "(premium)", ...) for the one CodeMie
* account shared by both `codemie` and `codemie-cli`.
*/
function registerUsageCommand(pi, getApiUrl, getAuthHeaders) {
pi.registerCommand("codemie-usage", {
description: "Show current CodeMie account budget/quota usage (all billing channels).",
handler: async (_args, ctx) => {
const apiUrl = getApiUrl();
const headers = getAuthHeaders();
if (!apiUrl || Object.keys(headers).length === 0) {
notifyOrPrint(ctx, "Not authenticated with CodeMie yet — run /login codemie first.");
return;
}
let data;
try {
data = await fetchBudgetUsage(apiUrl, headers);
} catch (error) {
notifyOrPrint(
ctx,
`Failed to fetch CodeMie budget usage (${error instanceof Error ? error.message : String(error)}).`
);
return;
}
// Best-effort supplement: the fast insights endpoint. Its failure
// should not block showing the (already-fetched) budget table above.
let insights;
try {
insights = summarizeCliInsights(await fetchCliInsightsSummary(apiUrl, headers));
} catch (error) {
console.error(
`[codemie-usage] Could not fetch fast CLI insights (${
error instanceof Error ? error.message : String(error)
}); showing budget table only.`
);
}
// Best-effort supplement: account-wide total, to surface orphaned spend
// (real money counted here but in no budget_usage row). Its failure must
// not block the budget table above. `summaryNote` (if set) is surfaced in
// the output so a missing orphaned row is explainable instead of silent.
let summaryTotal;
let summaryNote;
try {
const raw = await fetchSummaries(apiUrl, headers);
summaryTotal = extractTotalSpent(raw);
if (typeof summaryTotal !== "number") {
// Embed the raw shape in the footnote so the user can paste the table
// directly (no need to hunt stderr) if the field is still unreadable.
summaryNote = `could not read \`total_money_spent\` from /v1/analytics/summaries (shape: ${safeDump(raw)})`;
console.error(`[codemie-usage] ${summaryNote}`);
}
} catch (error) {
summaryNote = `could not fetch /v1/analytics/summaries (${error instanceof Error ? error.message : String(error)})`;
console.error(`[codemie-usage] ${summaryNote}; orphaned-spend row omitted.`);
}
const dashboardUrl = frontendFromApiBase(apiUrl);
const markdown = buildUsageMarkdown(data, insights, dashboardUrl, summaryTotal, summaryNote);
if (ctx.mode === "tui") {
pi.appendEntry("codemie-usage", { markdown });
} else if (ctx.hasUI) {
ctx.ui.notify(markdown, "info");
} else {
// print/json modes: notify() is a no-op (ctx.hasUI === false) — write directly.
console.log(markdown);
}
},
});
pi.registerEntryRenderer?.("codemie-usage", (entry) => {
const data = entry.data;
const mdTheme = getMarkdownTheme();
return new Markdown(data.markdown, 1, 0, mdTheme);
});
}
// ---------------------------------------------------------------------------
// Status bar — compact budget usage, refreshed periodically + after turns
// ---------------------------------------------------------------------------
// The backend aggregates spend in batches — measured ~5-10 min lag between an
// actual request and budget_usage reflecting it (confirmed empirically: cost
// showed up in the API response immediately, but budget_usage stayed frozen
// for 3+ minutes before updating). Polling more often than that just wastes
// requests, so there is no turn_end-triggered refresh — background poll only.
const USAGE_REFRESH_INTERVAL_MS = 10 * 60 * 1000;
// One account, several LiteLLM billing buckets (rows), keyed by project_name
// suffix: plain email = Web/Platform, "(cli)" = codemie-cli/* usage, "(premium)"
// = premium-model usage. `bucket: "web"` sums every row EXCEPT "(cli)" (i.e.
// what `codemie/*` spends); `bucket: "cli"` sums ONLY the "(cli)" row (i.e.
// what `codemie-cli/*` spends).
function isCliBucket(projectName) {
return typeof projectName === "string" && /\(cli\)\s*$/i.test(projectName);
}
/** Aggregate the row(s) belonging to one billing bucket into one compact line. */
function summarizeUsage(payload, theme, emoji, bucket) {
const rows = payload?.data?.rows;
if (!Array.isArray(rows) || rows.length === 0) return undefined;
const usedRows =
bucket === "cli" ? rows.filter((r) => isCliBucket(r.project_name)) : rows.filter((r) => !isCliBucket(r.project_name));
if (usedRows.length === 0) return undefined;
const spent = usedRows.reduce((s, r) => s + (typeof r.current_spending === "number" ? r.current_spending : 0), 0);
const limit = usedRows.reduce((s, r) => s + (typeof r.budget_limit === "number" ? r.budget_limit : 0), 0);
const pct = limit > 0 ? (spent / limit) * 100 : 0;
const color = pct >= 90 ? "error" : pct >= 70 ? "warning" : "dim";
const text = `${emoji} $${spent.toFixed(2)}/$${limit.toFixed(2)} (${pct.toFixed(1)}%)`;
return theme ? theme.fg(color, text) : text;
}
function isProviderModel(model, providerId) {
return model?.provider === providerId;
}
/**
* Registers a status-bar widget showing compact budget usage for one
* billing bucket, visible only while a model from `providerId` is active.
* Called once for `codemie` (bucket: "web") and once for `codemie-cli`
* (bucket: "cli") — both read the SAME account's budget_usage response
* (same getApiUrl/getAuthHeaders), just summing different row(s), so both
* can be shown side by side without clobbering each other.
*/
function registerUsageStatusBar(pi, providerId, statusKey, emoji, bucket, getApiUrl, getAuthHeaders) {
let timer;
let inFlight = false;
// Only show/refresh the widget while a model from this provider is
// actually active — switching to another provider hides it, since the
// budget it reports is irrelevant to whatever model is now active.
// Switching back brings it back.
let active = false;
async function refresh(ctx) {
if (!active || inFlight) return;
const apiUrl = getApiUrl();
const headers = getAuthHeaders();
if (!apiUrl || Object.keys(headers).length === 0) return; // not authenticated yet
inFlight = true;
try {
const payload = await fetchBudgetUsage(apiUrl, headers);
const summary = summarizeUsage(payload, ctx.ui.theme, emoji, bucket);
if (summary) ctx.ui.setStatus(statusKey, summary);
} catch {
// Best-effort background refresh — keep whatever status was shown before.
} finally {
inFlight = false;
}
}
function setActive(nowActive, ctx) {
if (active === nowActive) return;
active = nowActive;
if (active) {
refresh(ctx);
} else {
ctx.ui.setStatus(statusKey, undefined);
}
}
pi.on("session_start", async (_event, ctx) => {
active = isProviderModel(ctx.model, providerId);
if (active) refresh(ctx);
if (timer) clearInterval(timer);
timer = setInterval(() => refresh(ctx), USAGE_REFRESH_INTERVAL_MS);
});
pi.on("model_select", async (event, ctx) => {
setActive(isProviderModel(event.model, providerId), ctx);
});
pi.on("session_shutdown", async () => {
if (timer) {
clearInterval(timer);
timer = undefined;
}
});
}