Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a03d46cf74
|
||
|
|
82918ba5a8
|
No files matched your search
@@ -6,6 +6,42 @@ All notable changes to LLeMbas CLI. The format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.5.2] — 2026-10-25
|
||||||
|
|
||||||
|
Logging in again leaves one device, and a link that cannot connect says why.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Logging in again left an offline twin of this device on the instance.** A second
|
||||||
|
`lembas login` to the same instance overwrote the old token without signing it out, so the web
|
||||||
|
UI showed the device twice -- the new one linked, the old one offline and holding its agent
|
||||||
|
chats. The old token is now signed out, naming the new one, and a LLeMbas server 1.7.2 or later
|
||||||
|
moves the old device's agent chats to it. A sign-out that fails is said, with where to revoke it.
|
||||||
|
- **`lembas service` could not link when the login was given its CA as a relative path.** `login
|
||||||
|
--ca` kept the path as typed, and the service, started in another directory, did not find it and
|
||||||
|
dialled with no CA at all. The path is now kept in full, and a link to an instance whose
|
||||||
|
certificate is signed by a CA in the system's store gets through even without `--ca`, as the
|
||||||
|
rest of the CLI already did.
|
||||||
|
- **"Could not reach the instance" now says why**: a CA file given at login that is gone, a
|
||||||
|
certificate that is not trusted, or what else the connection met.
|
||||||
|
|
||||||
|
## [1.5.1] — 2026-10-23
|
||||||
|
|
||||||
|
A screenshot no longer makes the session compact.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **An image counted as half a million tokens.** The context estimate measured an image's base64
|
||||||
|
as text, so a 1–3 MB screenshot read as "context at 150–280%" the moment `view_image` (or an
|
||||||
|
`@` attachment) added it, and the session was compacted -- after every image, since the summary
|
||||||
|
drops pictures and the model looked again. The provider's own count, a few thousand tokens, was
|
||||||
|
never in question. An image now counts by its dimensions (28-pixel patches, at most 16k tokens;
|
||||||
|
a flat 1600 when the size cannot be read), everywhere the CLI estimates: the context meter,
|
||||||
|
pruning, `/context`, and the estimate a provider that reports no usage falls back to.
|
||||||
|
- **Screenshots are sent at 1600 px.** Any image over 1600 px or 500 KB is shrunk to 1600 px on
|
||||||
|
its longer edge when ImageMagick is there (only files over 3.5 MB were before): no more detail
|
||||||
|
than the model is billed for, and a lighter request.
|
||||||
|
|
||||||
## [1.5.0] — 2026-10-20
|
## [1.5.0] — 2026-10-20
|
||||||
|
|
||||||
Harness spec 2.0.0: background work is one thing — a job — for commands and subagents alike, and
|
Harness spec 2.0.0: background work is one thing — a job — for commands and subagents alike, and
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lembas-cli",
|
"name": "lembas-cli",
|
||||||
"version": "1.5.0",
|
"version": "1.5.2",
|
||||||
"description": "LLeMbas CLI: a terminal coding agent and project manager — OpenTUI, any LLM API, memory, skills, MCP, git-native.",
|
"description": "LLeMbas CLI: a terminal coding agent and project manager — OpenTUI, any LLM API, memory, skills, MCP, git-native.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
+20
-5
@@ -8,6 +8,8 @@
|
|||||||
// account lost the permission) ends the link for good: retrying it would only knock on a closed door.
|
// account lost the permission) ends the link for good: retrying it would only knock on a closed door.
|
||||||
import { existsSync, readFileSync } from "node:fs"
|
import { existsSync, readFileSync } from "node:fs"
|
||||||
import { hostname } from "node:os"
|
import { hostname } from "node:os"
|
||||||
|
import { resolve as resolvePath } from "node:path"
|
||||||
|
import { expandHome } from "../config/paths.ts"
|
||||||
import { remoteBlock } from "../config/remote.ts"
|
import { remoteBlock } from "../config/remote.ts"
|
||||||
import { refusedLocally } from "../project/local.ts"
|
import { refusedLocally } from "../project/local.ts"
|
||||||
import type { Mode } from "../config/schema.ts"
|
import type { Mode } from "../config/schema.ts"
|
||||||
@@ -143,7 +145,11 @@ export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
|
|||||||
o.onStatus?.(s)
|
o.onStatus?.(s)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
const ca = instance.ca && existsSync(instance.ca) ? readFileSync(instance.ca, "utf8") : undefined
|
// The CA the login was given, as a full path (an older login kept what was typed: relative, or
|
||||||
|
// with ~, which a service started elsewhere does not find). One that is gone is said, not skipped.
|
||||||
|
const caPath = instance.ca ? resolvePath(expandHome(instance.ca)) : undefined
|
||||||
|
const caGone = caPath && !existsSync(caPath) ? caPath : undefined
|
||||||
|
const loginCa = caPath && !caGone ? readFileSync(caPath, "utf8") : undefined
|
||||||
/** A 4400 to a hello that said 2: the protocols known were stale (or discovery said more than the
|
/** A 4400 to a hello that said 2: the protocols known were stale (or discovery said more than the
|
||||||
* link takes) — the next dial, at once, says 1; only a 4400 to that stops the link. */
|
* link takes) — the next dial, at once, says 1; only a 4400 to that stops the link. */
|
||||||
let downgraded = false
|
let downgraded = false
|
||||||
@@ -154,11 +160,20 @@ export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
|
|||||||
// What the instance speaks now: asked at every dial, since it may have been updated
|
// What the instance speaks now: asked at every dial, since it may have been updated
|
||||||
// since the login; what was said last when it cannot be asked.
|
// since the login; what was said last when it cannot be asked.
|
||||||
let protocols = instances()[instance.connection]?.protocols ?? instance.protocols
|
let protocols = instances()[instance.connection]?.protocols ?? instance.protocols
|
||||||
|
// What the socket trusts is what discovery just got through with: the login's CA, or the
|
||||||
|
// system's bundle when the instance's certificate needs it (Bun's own roots do not hold a
|
||||||
|
// private CA). Why discovery failed is why the dial will, said in the status.
|
||||||
|
let ca = loginCa
|
||||||
|
let why: string | undefined
|
||||||
try {
|
try {
|
||||||
const { discovery } = await discover(instance.base_url, ca ? { ca: instance.ca } : undefined)
|
const found = await discover(instance.base_url, loginCa ? { ca: caPath! } : undefined)
|
||||||
protocols = spoken(discovery)
|
protocols = spoken(found.discovery)
|
||||||
rememberProtocols(instance.connection, protocols)
|
rememberProtocols(instance.connection, protocols)
|
||||||
} catch {}
|
if (found.tls?.ca && found.tls.ca !== caPath) ca = readFileSync(found.tls.ca, "utf8")
|
||||||
|
} catch (e) {
|
||||||
|
why = (e as Error).message
|
||||||
|
}
|
||||||
|
const unreachable = [caGone && `the CA file given at login, ${caGone}, is not there`, why ?? "could not reach the instance"].filter(Boolean).join("; ")
|
||||||
const protocol = downgraded ? 1 : linkProtocol(protocols)
|
const protocol = downgraded ? 1 : linkProtocol(protocols)
|
||||||
const outcome = await new Promise<{ refused?: string; dropped?: string; final?: boolean; changed?: boolean }>((resolve) => {
|
const outcome = await new Promise<{ refused?: string; dropped?: string; final?: boolean; changed?: boolean }>((resolve) => {
|
||||||
let opened = false
|
let opened = false
|
||||||
@@ -202,7 +217,7 @@ export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
|
|||||||
if ([4401, 4403, 1008].includes(e.code)) return resolve({ refused: e.reason || "the instance refused this device's token" })
|
if ([4401, 4403, 1008].includes(e.code)) return resolve({ refused: e.reason || "the instance refused this device's token" })
|
||||||
// 4400: the hello's protocol is not one it speaks. Dialling again would say the same.
|
// 4400: the hello's protocol is not one it speaks. Dialling again would say the same.
|
||||||
if (e.code === 4400) return resolve({ refused: "the instance and this CLI speak different link protocols — update one of them", final: true })
|
if (e.code === 4400) return resolve({ refused: "the instance and this CLI speak different link protocols — update one of them", final: true })
|
||||||
resolve({ dropped: opened ? `the link dropped (${e.code}${e.reason ? `: ${e.reason}` : ""})` : "could not reach the instance" })
|
resolve({ dropped: opened ? `the link dropped (${e.code}${e.reason ? `: ${e.reason}` : ""})` : unreachable })
|
||||||
})
|
})
|
||||||
ws.addEventListener("error", () => {})
|
ws.addEventListener("error", () => {})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -255,9 +255,15 @@ export async function instanceInfo(base: string, token: string, tls: Tls | undef
|
|||||||
|
|
||||||
/** Revoke this machine's token on the instance. Best-effort: signing out locally goes ahead even
|
/** Revoke this machine's token on the instance. Best-effort: signing out locally goes ahead even
|
||||||
* when the instance cannot be reached, and says so. */
|
* when the instance cannot be reached, and says so. */
|
||||||
export async function signOut(baseUrl: string, token: string, tls: Tls | undefined): Promise<boolean> {
|
/** DELETE /api/v1/token: the token revokes itself. `replacedBy`, a login again's new token: an
|
||||||
|
* instance that knows it hands the old device's agent chats to the new one first (one that does
|
||||||
|
* not ignores the body and only revokes). */
|
||||||
|
export async function signOut(baseUrl: string, token: string, tls: Tls | undefined, replacedBy?: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const res = await call(`${baseUrl}/api/v1/token`, { method: "DELETE", headers: { authorization: `Bearer ${token}` } }, tls)
|
const headers: Record<string, string> = { authorization: `Bearer ${token}` }
|
||||||
|
if (replacedBy) headers["content-type"] = "application/json"
|
||||||
|
const body = replacedBy ? JSON.stringify({ replaced_by: replacedBy }) : undefined
|
||||||
|
const res = await call(`${baseUrl}/api/v1/token`, { method: "DELETE", headers, body }, tls)
|
||||||
return res.ok || res.status === 401
|
return res.ok || res.status === 401
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
|
|||||||
+17
-5
@@ -9,9 +9,9 @@
|
|||||||
// `login` again refreshes them and `logout` removes exactly them. Without a login nothing here is
|
// `login` again refreshes them and `logout` removes exactly them. Without a login nothing here is
|
||||||
// read, and LLeMbas CLI runs on its hand-written config as before.
|
// read, and LLeMbas CLI runs on its hand-written config as before.
|
||||||
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
||||||
import { join } from "node:path"
|
import { join, resolve } from "node:path"
|
||||||
import { parseDocument } from "yaml"
|
import { parseDocument } from "yaml"
|
||||||
import { globalPaths, isLocal, paths } from "../config/paths.ts"
|
import { expandHome, globalPaths, isLocal, paths } from "../config/paths.ts"
|
||||||
import { awaitToken, discover, LembasError, signOut, spoken, startDevice, type DeviceStart, type Discovery, type InstanceInfo, type ServedModel, type Tls } from "./client.ts"
|
import { awaitToken, discover, LembasError, signOut, spoken, startDevice, type DeviceStart, type Discovery, type InstanceInfo, type ServedModel, type Tls } from "./client.ts"
|
||||||
import { dropCache, fetchWebui, isChatModel, readCache, specFor } from "./webui.ts"
|
import { dropCache, fetchWebui, isChatModel, readCache, specFor } from "./webui.ts"
|
||||||
|
|
||||||
@@ -390,12 +390,17 @@ function webuiCount(): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function login(address: string, o: LoginOptions): Promise<LoginResult> {
|
export async function login(address: string, o: LoginOptions): Promise<LoginResult> {
|
||||||
const { discovery, tls } = await discover(address, o.ca ? { ca: o.ca } : undefined)
|
// Kept as a full path: `lembas service` and Desktop read it from another directory.
|
||||||
|
const ca = o.ca ? resolve(expandHome(o.ca)) : undefined
|
||||||
|
const { discovery, tls } = await discover(address, ca ? { ca } : undefined)
|
||||||
const start = await startDevice(discovery, tls)
|
const start = await startDevice(discovery, tls)
|
||||||
o.onCode(start, discovery)
|
o.onCode(start, discovery)
|
||||||
const granted = await awaitToken(discovery, start, tls, { signal: o.signal, sleep: o.sleep })
|
const granted = await awaitToken(discovery, start, tls, { signal: o.signal, sleep: o.sleep })
|
||||||
|
|
||||||
const connection = connectionNameFor(discovery.base_url, discovery.connection)
|
const connection = connectionNameFor(discovery.base_url, discovery.connection)
|
||||||
|
// Logging in again under the same name: the token being replaced, read before its file is.
|
||||||
|
const before = ownInstances()[connection]
|
||||||
|
const replaced = before?.base_url === discovery.base_url && existsSync(ownKeyFile(connection)) ? readFileSync(ownKeyFile(connection), "utf8").trim() : ""
|
||||||
mkdirSync(keyDir(), { recursive: true, mode: 0o700 })
|
mkdirSync(keyDir(), { recursive: true, mode: 0o700 })
|
||||||
writeFileSync(ownKeyFile(connection), granted.access_token + "\n", { mode: 0o600 })
|
writeFileSync(ownKeyFile(connection), granted.access_token + "\n", { mode: 0o600 })
|
||||||
chmodSync(ownKeyFile(connection), 0o600)
|
chmodSync(ownKeyFile(connection), 0o600)
|
||||||
@@ -403,13 +408,20 @@ export async function login(address: string, o: LoginOptions): Promise<LoginResu
|
|||||||
const instanceDefault = fetched.instance?.default_model ?? fetched.models.find((m) => m.default)?.id ?? undefined
|
const instanceDefault = fetched.instance?.default_model ?? fetched.models.find((m) => m.default)?.id ?? undefined
|
||||||
|
|
||||||
const changes: string[] = []
|
const changes: string[] = []
|
||||||
|
// The old token is signed out too: this machine holds one, the new one. Over the TLS that has
|
||||||
|
// just worked (the old login's CA may be what was wrong), naming the new token, so the instance
|
||||||
|
// moves the old device's agent chats to it instead of keeping an offline twin.
|
||||||
|
const handOver = async (old: string) => {
|
||||||
|
if (old && old !== granted.access_token && !(await signOut(discovery.base_url, old, tls, granted.access_token)))
|
||||||
|
changes.push("the previous login could not be signed out: revoke it under User → Security → Devices on the instance")
|
||||||
|
}
|
||||||
|
await handOver(replaced)
|
||||||
// Only logins this mode made: in local mode, the user's own login to the same instance is not
|
// Only logins this mode made: in local mode, the user's own login to the same instance is not
|
||||||
// signed out or replaced — it is not this project's.
|
// signed out or replaced — it is not this project's.
|
||||||
const known = ownInstances()
|
const known = ownInstances()
|
||||||
for (const [name, old] of Object.entries(known))
|
for (const [name, old] of Object.entries(known))
|
||||||
if (name !== connection && old.base_url === discovery.base_url) {
|
if (name !== connection && old.base_url === discovery.base_url) {
|
||||||
// The old token is signed out too: this machine holds one, the new one.
|
if (existsSync(ownKeyFile(name))) await handOver(readFileSync(ownKeyFile(name), "utf8").trim())
|
||||||
if (existsSync(ownKeyFile(name))) await signOut(old.base_url, readFileSync(ownKeyFile(name), "utf8").trim(), old.ca ? { ca: old.ca } : undefined)
|
|
||||||
changes.push(...replaceOld(name, connection, instanceDefault))
|
changes.push(...replaceOld(name, connection, instanceDefault))
|
||||||
delete known[name]
|
delete known[name]
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-2
@@ -1,17 +1,21 @@
|
|||||||
// Images for vision models: read, size-checked, downscaled when ImageMagick is there.
|
// Images for vision models: read, size-checked, downscaled when ImageMagick is there.
|
||||||
import { readFileSync, statSync } from "node:fs"
|
import { readFileSync, statSync } from "node:fs"
|
||||||
import { extname } from "node:path"
|
import { extname } from "node:path"
|
||||||
|
import { imageSize } from "../provider/tokens.ts"
|
||||||
import type { ImagePart } from "../provider/types.ts"
|
import type { ImagePart } from "../provider/types.ts"
|
||||||
|
|
||||||
export const IMAGE_TYPES: Record<string, string> = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" }
|
export const IMAGE_TYPES: Record<string, string> = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" }
|
||||||
const MAX_BYTES = 3_500_000
|
const MAX_BYTES = 3_500_000
|
||||||
|
const SHRINK_ABOVE_BYTES = 500_000
|
||||||
|
const MAX_EDGE = 1600
|
||||||
const magick = Bun.which("magick") ?? Bun.which("convert")
|
const magick = Bun.which("magick") ?? Bun.which("convert")
|
||||||
|
|
||||||
export function isImage(path: string): boolean {
|
export function isImage(path: string): boolean {
|
||||||
return extname(path).toLowerCase() in IMAGE_TYPES
|
return extname(path).toLowerCase() in IMAGE_TYPES
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The image as a part, or a reason it cannot be one. Large ones are shrunk to 1600 px if possible. */
|
/** The image as a part, or a reason it cannot be one. Anything over 1600 px or 500 KB is shrunk to
|
||||||
|
* 1600 px when ImageMagick is there. */
|
||||||
export function loadImage(path: string): ImagePart | string {
|
export function loadImage(path: string): ImagePart | string {
|
||||||
const mime = IMAGE_TYPES[extname(path).toLowerCase()]
|
const mime = IMAGE_TYPES[extname(path).toLowerCase()]
|
||||||
if (!mime) return `${path} is not a supported image (png, jpg, gif, webp)`
|
if (!mime) return `${path} is not a supported image (png, jpg, gif, webp)`
|
||||||
@@ -21,7 +25,13 @@ export function loadImage(path: string): ImagePart | string {
|
|||||||
} catch {
|
} catch {
|
||||||
return `${path} does not exist`
|
return `${path} does not exist`
|
||||||
}
|
}
|
||||||
if (size <= MAX_BYTES) return { type: "image", mime, data: readFileSync(path).toString("base64") }
|
const bytes = readFileSync(path)
|
||||||
|
const dims = imageSize(bytes)
|
||||||
|
// A screenshot of a large monitor is sent at 1600 px on its longer edge, as the LLeMbas server
|
||||||
|
// scales uploads: a model sees no more detail in 2560 px than it is billed tokens for, and a
|
||||||
|
// half-megabyte PNG is a slow request for nothing. Without ImageMagick it goes as it is.
|
||||||
|
const big = size > SHRINK_ABOVE_BYTES || (dims !== undefined && Math.max(dims.width, dims.height) > MAX_EDGE)
|
||||||
|
if (size <= MAX_BYTES && (!big || !magick)) return { type: "image", mime, data: bytes.toString("base64") }
|
||||||
if (!magick) return `${path} is ${(size / 1e6).toFixed(1)} MB — too large to send, and ImageMagick is not installed to shrink it`
|
if (!magick) return `${path} is ${(size / 1e6).toFixed(1)} MB — too large to send, and ImageMagick is not installed to shrink it`
|
||||||
// The coder named from the extension: ImageMagick otherwise decides by content, and a file named
|
// The coder named from the extension: ImageMagick otherwise decides by content, and a file named
|
||||||
// .png that is really SVG or MVG can make it read other files or fetch URLs.
|
// .png that is really SVG or MVG can make it read other files or fetch URLs.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { resolveKey } from "../config/load.ts"
|
|||||||
import { Parts, record, recordFailure, retrying, statusOf, SwapBanner } from "./common.ts"
|
import { Parts, record, recordFailure, retrying, statusOf, SwapBanner } from "./common.ts"
|
||||||
import { authHeaders, joinUrl, request, tlsFor } from "./http.ts"
|
import { authHeaders, joinUrl, request, tlsFor } from "./http.ts"
|
||||||
import { sseJson } from "./sse.ts"
|
import { sseJson } from "./sse.ts"
|
||||||
import { estimateTokens } from "./tokens.ts"
|
import { estimateTokens, estimateValue } from "./tokens.ts"
|
||||||
import { ProviderError, type ChatRequest, type Client, type DiscoveredModel, type Message, type ResolvedModel, type StreamEvent, type ToolCallPart } from "./types.ts"
|
import { ProviderError, type ChatRequest, type Client, type DiscoveredModel, type Message, type ResolvedModel, type StreamEvent, type ToolCallPart } from "./types.ts"
|
||||||
|
|
||||||
/** Thinking budgets by effort, when the model's config gives no effort_map. */
|
/** Thinking budgets by effort, when the model's config gives no effort_map. */
|
||||||
@@ -216,7 +216,7 @@ export class AnthropicClient implements Client {
|
|||||||
const reason = finish === "max_tokens" ? "length" : finish === "tool_use" || message.parts.some((p) => p.type === "tool_call") ? "tool_calls" : "stop"
|
const reason = finish === "max_tokens" ? "length" : finish === "tool_use" || message.parts.some((p) => p.type === "tool_call") ? "tool_calls" : "stop"
|
||||||
yield sawUsage
|
yield sawUsage
|
||||||
? { type: "usage", usage: { input, output, cached } }
|
? { type: "usage", usage: { input, output, cached } }
|
||||||
: { type: "usage", usage: { input: estimateTokens(JSON.stringify(body.messages)), output: message.parts.reduce((n, p) => n + estimateTokens(p.type === "tool_call" ? p.args : p.text), 0), estimated: true } }
|
: { type: "usage", usage: { input: estimateValue(body.messages), output: message.parts.reduce((n, p) => n + estimateTokens(p.type === "tool_call" ? p.args : p.text), 0), estimated: true } }
|
||||||
yield { type: "finish", reason, message }
|
yield { type: "finish", reason, message }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { DEFAULT_BUDGETS } from "./anthropic.ts"
|
|||||||
import { Parts, record, recordFailure, retrying, statusOf } from "./common.ts"
|
import { Parts, record, recordFailure, retrying, statusOf } from "./common.ts"
|
||||||
import { authHeaders, joinUrl, request, tlsFor } from "./http.ts"
|
import { authHeaders, joinUrl, request, tlsFor } from "./http.ts"
|
||||||
import { sseJson } from "./sse.ts"
|
import { sseJson } from "./sse.ts"
|
||||||
import { estimateTokens } from "./tokens.ts"
|
import { estimateTokens, estimateValue } from "./tokens.ts"
|
||||||
import { ProviderError, type ChatRequest, type Client, type DiscoveredModel, type Message, type ResolvedModel, type StreamEvent, type ToolCallPart } from "./types.ts"
|
import { ProviderError, type ChatRequest, type Client, type DiscoveredModel, type Message, type ResolvedModel, type StreamEvent, type ToolCallPart } from "./types.ts"
|
||||||
|
|
||||||
type Content = { role: "user" | "model"; parts: Record<string, unknown>[] }
|
type Content = { role: "user" | "model"; parts: Record<string, unknown>[] }
|
||||||
@@ -301,7 +301,7 @@ export class GeminiClient implements Client {
|
|||||||
}
|
}
|
||||||
const message = out.message()
|
const message = out.message()
|
||||||
if (slots.size && finish !== "length") finish = "tool_calls"
|
if (slots.size && finish !== "length") finish = "tool_calls"
|
||||||
yield usage ?? { type: "usage", usage: { input: estimateTokens(JSON.stringify(body.contents)), output: message.parts.reduce((n, p) => n + estimateTokens(p.type === "tool_call" ? p.args : p.text), 0), estimated: true } }
|
yield usage ?? { type: "usage", usage: { input: estimateValue(body.contents), output: message.parts.reduce((n, p) => n + estimateTokens(p.type === "tool_call" ? p.args : p.text), 0), estimated: true } }
|
||||||
yield { type: "finish", reason: finish, message }
|
yield { type: "finish", reason: finish, message }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import { resolveKey } from "../config/load.ts"
|
import { resolveKey } from "../config/load.ts"
|
||||||
import { Parts, record, recordFailure, retrying, statusOf } from "./common.ts"
|
import { Parts, record, recordFailure, retrying, statusOf } from "./common.ts"
|
||||||
import { authHeaders, joinUrl, request, tlsFor } from "./http.ts"
|
import { authHeaders, joinUrl, request, tlsFor } from "./http.ts"
|
||||||
import { estimateTokens } from "./tokens.ts"
|
import { estimateTokens, estimateValue } from "./tokens.ts"
|
||||||
import { ProviderError, type ChatRequest, type Client, type DiscoveredModel, type Message, type ResolvedModel, type StreamEvent } from "./types.ts"
|
import { ProviderError, type ChatRequest, type Client, type DiscoveredModel, type Message, type ResolvedModel, type StreamEvent } from "./types.ts"
|
||||||
|
|
||||||
export function toOllamaMessages(system: string, messages: Message[], vision: boolean): Record<string, unknown>[] {
|
export function toOllamaMessages(system: string, messages: Message[], vision: boolean): Record<string, unknown>[] {
|
||||||
@@ -160,7 +160,7 @@ export class OllamaClient implements Client {
|
|||||||
}
|
}
|
||||||
const message = out.message()
|
const message = out.message()
|
||||||
if (calls && finish !== "length") finish = "tool_calls"
|
if (calls && finish !== "length") finish = "tool_calls"
|
||||||
yield usage ?? { type: "usage", usage: { input: estimateTokens(JSON.stringify(body.messages)), output: message.parts.reduce((n, p) => n + estimateTokens(p.type === "tool_call" ? p.args : p.text), 0), estimated: true } }
|
yield usage ?? { type: "usage", usage: { input: estimateValue(body.messages), output: message.parts.reduce((n, p) => n + estimateTokens(p.type === "tool_call" ? p.args : p.text), 0), estimated: true } }
|
||||||
yield { type: "finish", reason: finish, message }
|
yield { type: "finish", reason: finish, message }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { learned, learnEfforts, learnNoProgress, learnNoStreamOptions } from "./
|
|||||||
import { historyArgs, Parts, record, recordFailure, retrying, statusOf, SwapBanner } from "./common.ts"
|
import { historyArgs, Parts, record, recordFailure, retrying, statusOf, SwapBanner } from "./common.ts"
|
||||||
import { sseJson } from "./sse.ts"
|
import { sseJson } from "./sse.ts"
|
||||||
import { ThinkSplitter } from "./think.ts"
|
import { ThinkSplitter } from "./think.ts"
|
||||||
import { estimateTokens } from "./tokens.ts"
|
import { estimateTokens, estimateValue } from "./tokens.ts"
|
||||||
import {
|
import {
|
||||||
ProviderError,
|
ProviderError,
|
||||||
type ChatRequest,
|
type ChatRequest,
|
||||||
@@ -274,7 +274,7 @@ export class OpenAIChatClient implements Client {
|
|||||||
const parts = message.parts
|
const parts = message.parts
|
||||||
if (!usage) {
|
if (!usage) {
|
||||||
const out = parts.reduce((n, p) => n + estimateTokens(p.type === "tool_call" ? p.name + p.args : p.text), 0)
|
const out = parts.reduce((n, p) => n + estimateTokens(p.type === "tool_call" ? p.name + p.args : p.text), 0)
|
||||||
usage = { type: "usage", usage: { input: estimateTokens(JSON.stringify(body.messages)), output: out, estimated: true } }
|
usage = { type: "usage", usage: { input: estimateValue(body.messages), output: out, estimated: true } }
|
||||||
}
|
}
|
||||||
yield usage
|
yield usage
|
||||||
yield { type: "finish", reason: finish, message }
|
yield { type: "finish", reason: finish, message }
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { historyArgs, Parts, record, recordFailure, retrying, statusOf, SwapBann
|
|||||||
import { authHeaders, joinUrl, request, tlsFor } from "./http.ts"
|
import { authHeaders, joinUrl, request, tlsFor } from "./http.ts"
|
||||||
import { contextFrom } from "./openai-chat.ts"
|
import { contextFrom } from "./openai-chat.ts"
|
||||||
import { sseJson } from "./sse.ts"
|
import { sseJson } from "./sse.ts"
|
||||||
import { estimateTokens } from "./tokens.ts"
|
import { estimateTokens, estimateValue } from "./tokens.ts"
|
||||||
import { ProviderError, type ChatRequest, type Client, type DiscoveredModel, type Message, type ResolvedModel, type StreamEvent } from "./types.ts"
|
import { ProviderError, type ChatRequest, type Client, type DiscoveredModel, type Message, type ResolvedModel, type StreamEvent } from "./types.ts"
|
||||||
|
|
||||||
type Item = Record<string, unknown>
|
type Item = Record<string, unknown>
|
||||||
@@ -161,7 +161,7 @@ export class ResponsesClient implements Client {
|
|||||||
if (finish !== "length" && message.parts.some((p) => p.type === "tool_call")) finish = "tool_calls"
|
if (finish !== "length" && message.parts.some((p) => p.type === "tool_call")) finish = "tool_calls"
|
||||||
yield usage ?? {
|
yield usage ?? {
|
||||||
type: "usage",
|
type: "usage",
|
||||||
usage: { input: estimateTokens(JSON.stringify(body.input)), output: message.parts.reduce((n, p) => n + estimateTokens(p.type === "tool_call" ? p.args : p.text), 0), estimated: true },
|
usage: { input: estimateValue(body.input), output: message.parts.reduce((n, p) => n + estimateTokens(p.type === "tool_call" ? p.args : p.text), 0), estimated: true },
|
||||||
}
|
}
|
||||||
yield { type: "finish", reason: finish, message }
|
yield { type: "finish", reason: finish, message }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,3 +4,87 @@ export const CHARS_PER_TOKEN = 4
|
|||||||
export function estimateTokens(text: string): number {
|
export function estimateTokens(text: string): number {
|
||||||
return Math.ceil(text.length / CHARS_PER_TOKEN)
|
return Math.ceil(text.length / CHARS_PER_TOKEN)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An image whose size cannot be read counts as this many tokens (a 1600 px screenshot, roughly). */
|
||||||
|
export const IMAGE_FALLBACK_TOKENS = 1600
|
||||||
|
/** Pixels per token edge: Qwen-VL's 28 px patches; OpenAI's and Anthropic's tiles land near it. */
|
||||||
|
const PATCH = 28
|
||||||
|
/** No image costs more than this (vLLM's Qwen default max_pixels / 784). */
|
||||||
|
const IMAGE_MAX_TOKENS = 16_384
|
||||||
|
/** A string this long made only of base64 characters is an image's bytes, not text. */
|
||||||
|
const BASE64_MIN = 2_000
|
||||||
|
const BASE64 = /^[A-Za-z0-9+/=_-]+$/
|
||||||
|
|
||||||
|
/** The width and height in an image's first bytes (PNG, JPEG, GIF, WebP), or undefined. */
|
||||||
|
export function imageSize(bytes: Uint8Array): { width: number; height: number } | undefined {
|
||||||
|
const at = (i: number) => bytes[i] ?? 0
|
||||||
|
const be16 = (i: number) => (at(i) << 8) | at(i + 1)
|
||||||
|
const le16 = (i: number) => at(i) | (at(i + 1) << 8)
|
||||||
|
const be32 = (i: number) => ((at(i) << 24) | (at(i + 1) << 16) | (at(i + 2) << 8) | at(i + 3)) >>> 0
|
||||||
|
if (at(0) === 0x89 && at(1) === 0x50 && at(2) === 0x4e && at(3) === 0x47) return { width: be32(16), height: be32(20) }
|
||||||
|
if (at(0) === 0x47 && at(1) === 0x49 && at(2) === 0x46) return { width: le16(6), height: le16(8) }
|
||||||
|
if (at(0) === 0x52 && at(1) === 0x49 && at(2) === 0x46 && at(3) === 0x46 && at(8) === 0x57 && at(9) === 0x45) {
|
||||||
|
const chunk = String.fromCharCode(at(12), at(13), at(14), at(15))
|
||||||
|
if (chunk === "VP8X") return { width: 1 + (at(24) | (at(25) << 8) | (at(26) << 16)), height: 1 + (at(27) | (at(28) << 8) | (at(29) << 16)) }
|
||||||
|
if (chunk === "VP8 ") return { width: le16(26) & 0x3fff, height: le16(28) & 0x3fff }
|
||||||
|
if (chunk === "VP8L") {
|
||||||
|
const b = (at(21) | (at(22) << 8) | (at(23) << 16) | (at(24) << 24)) >>> 0
|
||||||
|
return { width: (b & 0x3fff) + 1, height: ((b >> 14) & 0x3fff) + 1 }
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
if (at(0) === 0xff && at(1) === 0xd8) {
|
||||||
|
// Walk the segments to a start-of-frame marker (C0–CF, except C4, C8, CC).
|
||||||
|
let i = 2
|
||||||
|
while (i + 9 < bytes.length) {
|
||||||
|
if (at(i) !== 0xff) return undefined
|
||||||
|
const marker = at(i + 1)
|
||||||
|
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
||||||
|
return { width: be16(i + 7), height: be16(i + 5) }
|
||||||
|
}
|
||||||
|
i += 2 + be16(i + 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an image costs a model, from its dimensions -- never from its encoding. Counting the
|
||||||
|
* base64 as text made a 1.5 MB screenshot "512k tokens": the CLI then compacted after every
|
||||||
|
* view_image, at "context 215%", while the provider charged a few thousand. Only the head is
|
||||||
|
* decoded (enough for the header, and a JPEG's frame marker in all but odd files).
|
||||||
|
*/
|
||||||
|
export function imageTokens(base64: string): number {
|
||||||
|
const head = base64.replace(/^data:[^,]*,/, "").slice(0, 87_384) // 64 KB of bytes
|
||||||
|
let size: { width: number; height: number } | undefined
|
||||||
|
try {
|
||||||
|
size = imageSize(Buffer.from(head.slice(0, head.length - (head.length % 4)), "base64"))
|
||||||
|
} catch {
|
||||||
|
size = undefined
|
||||||
|
}
|
||||||
|
if (!size || !size.width || !size.height) return IMAGE_FALLBACK_TOKENS
|
||||||
|
return Math.min(IMAGE_MAX_TOKENS, Math.ceil(size.width / PATCH) * Math.ceil(size.height / PATCH))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImageData(text: string): boolean {
|
||||||
|
if (text.startsWith("data:image/")) return true
|
||||||
|
return text.length >= BASE64_MIN && BASE64.test(text.slice(0, 4096))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokens for any value about to be sent or kept in history -- a message, a request body in any
|
||||||
|
* dialect: its JSON measured as text, except that an image is counted by `imageTokens`. Images
|
||||||
|
* are found by shape, so the one rule covers every wire format (an `image_url` data URL, an
|
||||||
|
* Anthropic or Gemini base64 `data`, Ollama's `images` list, the history's own `ImagePart`).
|
||||||
|
*/
|
||||||
|
export function estimateValue(value: unknown): number {
|
||||||
|
let images = 0
|
||||||
|
const text = JSON.stringify(value, (_key, v) => {
|
||||||
|
if (typeof v === "string" && isImageData(v)) {
|
||||||
|
images += imageTokens(v)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
})
|
||||||
|
return estimateTokens(text ?? "") + images
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
// up to what the server last reported when it reported anything.
|
// up to what the server last reported when it reported anything.
|
||||||
import type { Engine } from "./engine.ts"
|
import type { Engine } from "./engine.ts"
|
||||||
import { contextWindow } from "../provider/discover.ts"
|
import { contextWindow } from "../provider/discover.ts"
|
||||||
import { estimateTokens } from "../provider/tokens.ts"
|
import { estimateTokens, imageTokens } from "../provider/tokens.ts"
|
||||||
import { toSpec, type Tool } from "../tool/tool.ts"
|
import { toSpec, type Tool } from "../tool/tool.ts"
|
||||||
|
|
||||||
export interface ContextRow {
|
export interface ContextRow {
|
||||||
@@ -29,7 +29,7 @@ export function contextBreakdown(engine: Engine, system: string, instructions: {
|
|||||||
else if (p.type === "tool_call") by.calls += estimateTokens(p.name + p.args)
|
else if (p.type === "tool_call") by.calls += estimateTokens(p.name + p.args)
|
||||||
else {
|
else {
|
||||||
imageCount++
|
imageCount++
|
||||||
by.images += 800 // providers bill an image at roughly this for a typical screenshot
|
by.images += imageTokens(p.data) // by its dimensions, as contextUsed counts it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { fill, promptText } from "../prompt/assemble.ts"
|
|||||||
import { resolveCall } from "../tool/names.ts"
|
import { resolveCall } from "../tool/names.ts"
|
||||||
import { contextWindow } from "../provider/discover.ts"
|
import { contextWindow } from "../provider/discover.ts"
|
||||||
import { summaryTurns, type Store } from "./store.ts"
|
import { summaryTurns, type Store } from "./store.ts"
|
||||||
import { estimateTokens } from "../provider/tokens.ts"
|
import { estimateTokens, estimateValue } from "../provider/tokens.ts"
|
||||||
import { duration } from "../duration.ts"
|
import { duration } from "../duration.ts"
|
||||||
|
|
||||||
export interface EngineOptions {
|
export interface EngineOptions {
|
||||||
@@ -244,7 +244,7 @@ export class Engine {
|
|||||||
* last reply's output but will not be in the next request. */
|
* last reply's output but will not be in the next request. */
|
||||||
contextUsed(): number | undefined {
|
contextUsed(): number | undefined {
|
||||||
if (!this.usage) return undefined
|
if (!this.usage) return undefined
|
||||||
return this.usage.input + this.usage.output - this.droppedThinking() + estimateTokens(JSON.stringify(this.messages.slice(this.usageAt)))
|
return this.usage.input + this.usage.output - this.droppedThinking() + estimateValue(this.messages.slice(this.usageAt))
|
||||||
}
|
}
|
||||||
|
|
||||||
private droppedThinking(): number {
|
private droppedThinking(): number {
|
||||||
@@ -262,7 +262,7 @@ export class Engine {
|
|||||||
let saved = 0
|
let saved = 0
|
||||||
for (let i = this.messages.length - 1; i >= 0; i--) {
|
for (let i = this.messages.length - 1; i >= 0; i--) {
|
||||||
const m = this.messages[i]!
|
const m = this.messages[i]!
|
||||||
const size = estimateTokens(JSON.stringify(m))
|
const size = estimateValue(m)
|
||||||
if (recent < protect) {
|
if (recent < protect) {
|
||||||
recent += size
|
recent += size
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -425,3 +425,26 @@ test("the link says protocol 1 to an instance that lists only 1, and stops on 44
|
|||||||
expect(three.detail).toBe("the instance and this CLI speak different link protocols — update one of them")
|
expect(three.detail).toBe("the instance and this CLI speak different link protocols — update one of them")
|
||||||
expect(dials).toBe(5)
|
expect(dials).toBe(5)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("a link that cannot dial says why: a CA file gone since the login, and what discovery met", async () => {
|
||||||
|
mkdirSync(paths.config, { recursive: true })
|
||||||
|
const closed = Bun.serve({ port: 0, fetch: () => new Response("no") })
|
||||||
|
const port = closed.port
|
||||||
|
closed.stop(true)
|
||||||
|
writeFileSync(join(paths.config, "lembas.json"), JSON.stringify({ instances: { fake: { base_url: `http://127.0.0.1:${port}`, connection: "fake", ca: "gone/ca.crt", logged_in_at: "" } } }))
|
||||||
|
mkdirSync(join(paths.config, "lembas"), { recursive: true })
|
||||||
|
writeFileSync(join(paths.config, "lembas", "fake.key"), "lmb_device\n", { mode: 0o600 })
|
||||||
|
const stop = new AbortController()
|
||||||
|
const waits: string[] = []
|
||||||
|
await runLink({
|
||||||
|
backoffMs: [10, 20],
|
||||||
|
signal: stop.signal,
|
||||||
|
onStatus: (s) => {
|
||||||
|
if (s.state !== "waiting") return
|
||||||
|
waits.push(s.detail ?? "")
|
||||||
|
stop.abort()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(waits[0]).toContain(`the CA file given at login, ${join(process.cwd(), "gone/ca.crt")}, is not there`)
|
||||||
|
expect(waits[0]).not.toContain("could not reach the instance;")
|
||||||
|
})
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
|
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import { createApp } from "../src/app.ts"
|
||||||
|
import type { AskReply, Event } from "../src/bus/index.ts"
|
||||||
|
import { paths } from "../src/config/paths.ts"
|
||||||
|
import { estimateValue, IMAGE_FALLBACK_TOKENS, imageSize, imageTokens } from "../src/provider/tokens.ts"
|
||||||
|
import { delta, fakeProvider, toolCall, usage, type Fake } from "./fake-provider.ts"
|
||||||
|
|
||||||
|
/** A PNG-shaped file of `bytes` bytes that says it is `w`×`h` (header only; the rest is filler). */
|
||||||
|
function png(w: number, h: number, bytes: number): Buffer {
|
||||||
|
const out = Buffer.alloc(bytes, 0x41)
|
||||||
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]).copy(out, 0)
|
||||||
|
out.writeUInt32BE(w, 16)
|
||||||
|
out.writeUInt32BE(h, 20)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
let fake: Fake | undefined
|
||||||
|
afterEach(() => fake?.stop())
|
||||||
|
|
||||||
|
describe("an image counts by its dimensions, never its encoding", () => {
|
||||||
|
test("sizes are read from PNG, GIF and JPEG headers", () => {
|
||||||
|
expect(imageSize(png(2560, 1440, 64))).toEqual({ width: 2560, height: 1440 })
|
||||||
|
const gif = Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x40, 0x01, 0xf0, 0x00])
|
||||||
|
expect(imageSize(gif)).toEqual({ width: 320, height: 240 })
|
||||||
|
// SOI, an APP0 segment of 16 bytes, then SOF0 with height 600 and width 800.
|
||||||
|
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, ...Array(14).fill(0), 0xff, 0xc0, 0x00, 0x11, 0x08, 0x02, 0x58, 0x03, 0x20, 0x03])
|
||||||
|
expect(imageSize(jpeg)).toEqual({ width: 800, height: 600 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a 1.5 MB 2560×1440 screenshot is thousands of tokens, not half a million", () => {
|
||||||
|
const data = png(2560, 1440, 1_500_000).toString("base64")
|
||||||
|
expect(imageTokens(data)).toBe(Math.ceil(2560 / 28) * Math.ceil(1440 / 28))
|
||||||
|
const message = { role: "user", parts: [{ type: "text", text: "look" }, { type: "image", mime: "image/png", data }] }
|
||||||
|
expect(estimateValue(message)).toBeLessThan(5_000)
|
||||||
|
// The same image in each dialect's wire shape.
|
||||||
|
expect(estimateValue([{ content: [{ type: "image_url", image_url: { url: `data:image/png;base64,${data}` } }] }])).toBeLessThan(5_000)
|
||||||
|
expect(estimateValue([{ content: [{ type: "image", source: { type: "base64", media_type: "image/png", data } }] }])).toBeLessThan(5_000)
|
||||||
|
expect(estimateValue([{ role: "user", content: "look", images: [data] }])).toBeLessThan(5_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("an image whose size cannot be read has a flat cost", () => {
|
||||||
|
expect(imageTokens(Buffer.alloc(300_000, 7).toString("base64"))).toBe(IMAGE_FALLBACK_TOKENS)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("view_image of a 1.5 MB screenshot at 20% of the window does not compact", async () => {
|
||||||
|
fake = fakeProvider([
|
||||||
|
{ chunks: [toolCall(0, "c1", "view_image", '{"path":"shot.png"}'), usage(52_000, 50)] },
|
||||||
|
{ chunks: [delta({ content: "a screenshot" }), usage(56_000, 5)] },
|
||||||
|
])
|
||||||
|
mkdirSync(paths.config, { recursive: true })
|
||||||
|
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n f:\n dialect: openai-chat\n base_url: ${fake.url}\n models:\n m: { vision: true, context: 262144 }\n`, { mode: 0o600 })
|
||||||
|
writeFileSync(join(paths.config, "config.yaml"), "model: f/m\n")
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "ph-img-"))
|
||||||
|
writeFileSync(join(root, "shot.png"), png(2560, 1440, 1_500_000))
|
||||||
|
const app = createApp({ cwd: root, mode: "edit", store: false, asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) } })
|
||||||
|
const notices: string[] = []
|
||||||
|
app.bus.on((e: Event) => {
|
||||||
|
if (e.type === "notice") notices.push(e.message)
|
||||||
|
})
|
||||||
|
await app.engine.prompt("look at shot.png")
|
||||||
|
expect(notices.filter((n) => n.includes("compacting") || n.includes("pruned"))).toEqual([])
|
||||||
|
expect(fake.requests.length).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -22,6 +22,8 @@ interface Fake {
|
|||||||
stop(): void
|
stop(): void
|
||||||
polls: number
|
polls: number
|
||||||
revoked: string[]
|
revoked: string[]
|
||||||
|
/** What each DELETE /api/v1/token said it was replaced by. */
|
||||||
|
replacedBy: (string | undefined)[]
|
||||||
models: unknown[]
|
models: unknown[]
|
||||||
approveAfter: number
|
approveAfter: number
|
||||||
deny?: boolean
|
deny?: boolean
|
||||||
@@ -52,6 +54,7 @@ function lembas(o: Partial<Pick<Fake, "approveAfter" | "deny" | "protocol" | "mo
|
|||||||
stop: () => {},
|
stop: () => {},
|
||||||
polls: 0,
|
polls: 0,
|
||||||
revoked: [],
|
revoked: [],
|
||||||
|
replacedBy: [],
|
||||||
approveAfter: o.approveAfter ?? 1,
|
approveAfter: o.approveAfter ?? 1,
|
||||||
deny: o.deny,
|
deny: o.deny,
|
||||||
protocol: o.protocol ?? PROTOCOL,
|
protocol: o.protocol ?? PROTOCOL,
|
||||||
@@ -98,6 +101,8 @@ function lembas(o: Partial<Pick<Fake, "approveAfter" | "deny" | "protocol" | "mo
|
|||||||
}
|
}
|
||||||
if (u.pathname === "/api/v1/token" && req.method === "DELETE") {
|
if (u.pathname === "/api/v1/token" && req.method === "DELETE") {
|
||||||
state.revoked.push(auth ?? "")
|
state.revoked.push(auth ?? "")
|
||||||
|
const text = await req.text()
|
||||||
|
state.replacedBy.push(text ? (JSON.parse(text) as { replaced_by?: string }).replaced_by : undefined)
|
||||||
return new Response(null, { status: 204 })
|
return new Response(null, { status: 204 })
|
||||||
}
|
}
|
||||||
if (!ours) return new Response("no", { status: 401 })
|
if (!ours) return new Response("no", { status: 401 })
|
||||||
@@ -340,6 +345,33 @@ test("login again reads the models now", async () => {
|
|||||||
expect(JSON.parse(readFileSync(cacheFile("example"), "utf8")).models[0].id).toBe("llama")
|
expect(JSON.parse(readFileSync(cacheFile("example"), "utf8")).models[0].id).toBe("llama")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("login again signs the old token out, naming the new one, so its chats follow", async () => {
|
||||||
|
fake = lembas()
|
||||||
|
await login(fake.url, { ...quick, onCode: () => {} })
|
||||||
|
fake.token = "lmb_newer"
|
||||||
|
fake.polls = 0
|
||||||
|
const r = await login(fake.url, { ...quick, onCode: () => {} })
|
||||||
|
expect(fake.revoked).toEqual(["Bearer lmb_secret"])
|
||||||
|
expect(fake.replacedBy).toEqual(["lmb_newer"])
|
||||||
|
expect(readFileSync(keyFile(r.connection), "utf8").trim()).toBe("lmb_newer")
|
||||||
|
expect(r.changes.join("\n")).not.toContain("could not be signed out")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a CA given as a relative path is kept as a full one", async () => {
|
||||||
|
fake = lembas()
|
||||||
|
const dir = join(paths.config, "certs")
|
||||||
|
mkdirSync(dir, { recursive: true })
|
||||||
|
writeFileSync(join(dir, "ca.crt"), "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----\n")
|
||||||
|
const cwd = process.cwd()
|
||||||
|
process.chdir(paths.config)
|
||||||
|
try {
|
||||||
|
const r = await login(fake.url, { ...quick, ca: "certs/ca.crt", onCode: () => {} })
|
||||||
|
expect(instances()[r.connection]!.ca).toBe(join(dir, "ca.crt"))
|
||||||
|
} finally {
|
||||||
|
process.chdir(cwd)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("logout revokes the token and removes exactly what login wrote", async () => {
|
test("logout revokes the token and removes exactly what login wrote", async () => {
|
||||||
writeFileSync(join(paths.config, "connections.yaml"), "connections:\n local:\n dialect: openai-chat\n base_url: http://x/v1\n models: { m: {} }\n", { mode: 0o600 })
|
writeFileSync(join(paths.config, "connections.yaml"), "connections:\n local:\n dialect: openai-chat\n base_url: http://x/v1\n models: { m: {} }\n", { mode: 0o600 })
|
||||||
writeFileSync(join(paths.config, "config.yaml"), "voice:\n stt:\n provider: openai\n base_url: http://stt.local/v1\n")
|
writeFileSync(join(paths.config, "config.yaml"), "voice:\n stt:\n provider: openai\n base_url: http://stt.local/v1\n")
|
||||||
|
|||||||
Reference in new issue
Block a user