Compare commits

...

12 Commits

Author SHA1 Message Date
Armin Ronacher 9b3a205917 fix(coding-agent): isolate summarization requests
CI / build-check-test (push) Has been cancelled
2026-07-22 23:54:57 +02:00
Armin Ronacher ecb9410c5c fix(coding-agent): let manual updates bypass skip check
closes #6977
2026-07-22 20:53:35 +02:00
Armin Ronacher bb3d7d399c feat(coding-agent): expose session metadata to bash tools (#6967) 2026-07-22 20:15:02 +02:00
David Brailovsky c55ae2faa5 fix(coding-agent): display path of sibling dependent extensions (#6964)
fixes: #6619
2026-07-22 16:08:21 +02:00
Christian Klotz 75e6123aba fix(coding-agent): speed up external editor launch (#6903)
* fix(coding-agent): speed up external editor launch

Fixes #6774

* refactor(coding-agent): clarify external editor handling

* refactor(coding-agent): simplify external editor result

* refactor(coding-agent): model external editor lifecycle

* fix(coding-agent): rename external editor test fake

* fix(coding-agent): secure external editor temp files

* fix(coding-agent): simplify external editor temp path

* fix(coding-agent): restore system temp editor files
2026-07-22 15:49:01 +02:00
Saryev Rustam 7b52cef2e6 feat(ai): add OpenRouter OAuth support (#6927)
Closes #6814
2026-07-22 15:48:39 +02:00
David Brailovsky fe42ba5b38 write tui debug/crash logs into the configured pi agent dir (#6958)
fixes #6652
2026-07-22 15:48:25 +02:00
Mario Zechner 346c85473d Merge branch 'agent-harness-tools' 2026-07-22 15:36:28 +02:00
Mario Zechner e32c1491b5 feat(agent): align harness execution tools 2026-07-22 15:36:06 +02:00
Armin Ronacher 5dc40fee33 feat(ai): derive generated model types from JSON 2026-07-22 13:13:36 +02:00
Mario Zechner 2d0b2294cd feat(agent): merge main into agent-harness-tools 2026-07-22 11:56:35 +02:00
Mario Zechner 37eb243d26 feat(agent): add AgentHarness execution tools 2026-07-21 17:03:28 +02:00
113 changed files with 3963 additions and 4984 deletions
+1
View File
@@ -5045,6 +5045,7 @@
"license": "MIT",
"dependencies": {
"@earendil-works/pi-ai": "^0.81.1",
"diff": "8.0.4",
"ignore": "7.0.5",
"typebox": "1.1.38",
"yaml": "2.9.0"
+12
View File
@@ -2,6 +2,18 @@
## [Unreleased]
### Breaking Changes
- Replaced `AgentHarness`'s `ExecutionEnv` dependency and context-free `AgentTool` inputs with application-defined `toolContext` values and context-aware `AgentHarnessTool` definitions.
### Added
- Added context-aware `read`, `write`, `edit`, and `bash` harness tools backed by `ExecutionEnv`, including async bash execution preparation.
### Changed
- Aligned harness tool path handling, edit serialization, shell output capture, explicit non-inherited environments, and cross-platform process cleanup with coding-agent behavior.
## [0.81.1] - 2026-07-21
### Added
+9 -1
View File
@@ -43,6 +43,7 @@ Harness config is the latest runtime configuration set by the application or ext
- thinking level
- tools
- active tool names
- tool context source
- resources
- stream options
- system prompt or system prompt provider
@@ -66,6 +67,7 @@ A turn snapshot is the concrete state used for one LLM turn. It is created by `c
- thinking level
- all tools
- active tools
- resolved tool context
- stream options
- derived session id
@@ -73,8 +75,14 @@ Static option values are used directly. System-prompt provider callbacks are inv
Resource arrays are shallow-copied when a snapshot is created. Individual skill and prompt-template objects are not deep-copied.
`toolContext` is application-defined and required when the configured tools require a non-`undefined` context. A static value is reused, while a zero-argument sync or async provider is resolved once for each turn snapshot. Harness tools receive that resolved value when they execute. Individual tools can structurally require only the context fields they use.
Stream options are shallow-copied when a snapshot is created. `headers` and `metadata` maps are shallow-copied; their values are not deep-copied. Credentials from `getApiKeyAndHeaders()` are resolved per provider request so expiring tokens can refresh, but the configured stream options and derived session id come from the current turn snapshot.
### Built-in tools
The package exports `createReadTool()`, `createWriteTool()`, `createEditTool()`, and `createBashTool()`. They perform filesystem and shell operations exclusively through the `ExecutionEnv` supplied in their tool context. Each tool structurally requires the shared `ExecutionToolContext`, containing `env: ExecutionEnv`; applications may provide additional fields. `createReadTool()` accepts an optional image processor for host-provided conversion and resizing without imposing an image-processing dependency on the agent package. `createBashTool()` accepts an async `prepare` hook that can mutate the command, working directory, environment, and environment-inheritance policy using the current tool context.
### Session
The session contains persisted entries only. Session reads return persisted state and do not include queued writes.
@@ -266,7 +274,7 @@ Done:
- Added `setTools(tools, activeToolNames?)`.
- Added `setActiveTools(toolNames)`.
- Invalid active tool names reject with `AgentHarnessError`.
- Added generic app tool shape via `AgentHarness<TSkill, TPromptTemplate, TTool>`.
- Added generic app tool and context shapes via `AgentHarness<TContext, TSkill, TPromptTemplate, TTool>`.
- Exported `QueueMode` from core types.
- Added `AgentHarnessOptions.steeringMode` and `followUpMode`.
- Added live `getSteeringMode()` / `setSteeringMode()` and `getFollowUpMode()` / `setFollowUpMode()`.
+1
View File
@@ -30,6 +30,7 @@
},
"dependencies": {
"@earendil-works/pi-ai": "^0.81.1",
"diff": "8.0.4",
"ignore": "7.0.5",
"typebox": "1.1.38",
"yaml": "2.9.0"
+38 -16
View File
@@ -34,8 +34,10 @@ import type {
AgentHarnessResources,
AgentHarnessStreamOptions,
AgentHarnessStreamOptionsPatch,
AgentHarnessSystemPrompt,
AgentHarnessTool,
AgentHarnessToolContextSource,
CompactResult,
ExecutionEnv,
NavigateTreeResult,
PendingSessionWrite,
PromptTemplate,
@@ -149,12 +151,14 @@ function normalizeHookError(error: unknown): AgentHarnessError {
}
interface AgentHarnessTurnState<
TContext extends object | undefined,
TSkill extends Skill = Skill,
TPromptTemplate extends PromptTemplate = PromptTemplate,
TTool extends AgentTool = AgentTool,
TTool extends AgentHarnessTool<TContext> = AgentHarnessTool<TContext>,
> {
messages: AgentMessage[];
resources: AgentHarnessResources<TSkill, TPromptTemplate>;
toolContext: TContext;
streamOptions: AgentHarnessStreamOptions;
sessionId: string;
systemPrompt: string;
@@ -165,11 +169,11 @@ interface AgentHarnessTurnState<
}
export class AgentHarness<
TContext extends object | undefined = undefined,
TSkill extends Skill = Skill,
TPromptTemplate extends PromptTemplate = PromptTemplate,
TTool extends AgentTool = AgentTool,
TTool extends AgentHarnessTool<TContext> = AgentHarnessTool<TContext>,
> {
readonly env: ExecutionEnv;
private session: Session;
readonly models: Models;
private phase: AgentHarnessPhase = "idle";
@@ -178,7 +182,8 @@ export class AgentHarness<
private pendingSessionWrites: PendingSessionWrite[] = [];
private model: Model<any>;
private thinkingLevel: ThinkingLevel;
private systemPrompt: AgentHarnessOptions<TSkill, TPromptTemplate, TTool>["systemPrompt"];
private systemPrompt: AgentHarnessSystemPrompt<TContext, TSkill, TPromptTemplate, TTool> | undefined;
private toolContext: AgentHarnessToolContextSource<TContext> | undefined;
private streamOptions: AgentHarnessStreamOptions;
private retry: RetryPolicy | undefined;
private resources: AgentHarnessResources<TSkill, TPromptTemplate>;
@@ -191,14 +196,14 @@ export class AgentHarness<
private nextTurnQueue: AgentMessage[] = [];
private handlers = new Map<string, Set<AgentHarnessHandler>>();
constructor(options: AgentHarnessOptions<TSkill, TPromptTemplate, TTool>) {
this.env = options.env;
constructor(options: AgentHarnessOptions<TContext, TSkill, TPromptTemplate, TTool>) {
this.session = options.session;
this.models = options.models;
this.resources = options.resources ?? {};
this.streamOptions = cloneStreamOptions(options.streamOptions);
this.retry = options.retry;
this.systemPrompt = options.systemPrompt;
this.toolContext = options.toolContext;
this.validateUniqueNames(
(options.tools ?? []).map((tool) => tool.name),
"Duplicate tool name(s)",
@@ -332,10 +337,25 @@ export class AgentHarness<
};
}
private async createTurnState(): Promise<AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>> {
private async resolveToolContext(): Promise<TContext> {
if (typeof this.toolContext === "function") {
return await (this.toolContext as () => TContext | Promise<TContext>)();
}
return this.toolContext as TContext;
}
private bindToolContext(tool: TTool, context: TContext): AgentTool {
return {
...tool,
execute: (toolCallId, params, signal, onUpdate) => tool.execute(toolCallId, params, signal, onUpdate, context),
};
}
private async createTurnState(): Promise<AgentHarnessTurnState<TContext, TSkill, TPromptTemplate, TTool>> {
const context = await this.session.buildContext();
const resources = this.getResources();
const sessionMetadata = await this.session.getMetadata();
const toolContext = await this.resolveToolContext();
const tools = [...this.tools.values()];
const activeTools = this.activeToolNames
.map((name) => this.tools.get(name))
@@ -345,7 +365,6 @@ export class AgentHarness<
systemPrompt = this.systemPrompt;
} else if (this.systemPrompt) {
systemPrompt = await this.systemPrompt({
env: this.env,
session: this.session,
model: this.model,
thinkingLevel: this.thinkingLevel,
@@ -356,6 +375,7 @@ export class AgentHarness<
return {
messages: context.messages,
resources,
toolContext,
streamOptions: cloneStreamOptions(this.streamOptions),
sessionId: sessionMetadata.id,
systemPrompt,
@@ -367,17 +387,19 @@ export class AgentHarness<
}
private createContext(
turnState: AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>,
turnState: AgentHarnessTurnState<TContext, TSkill, TPromptTemplate, TTool>,
systemPrompt?: string,
): AgentContext {
return {
systemPrompt: systemPrompt ?? turnState.systemPrompt,
messages: turnState.messages.slice(),
tools: turnState.activeTools.slice(),
tools: turnState.activeTools.map((tool) => this.bindToolContext(tool, turnState.toolContext)),
};
}
private createStreamFn(getTurnState: () => AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>): StreamFn {
private createStreamFn(
getTurnState: () => AgentHarnessTurnState<TContext, TSkill, TPromptTemplate, TTool>,
): StreamFn {
return async (model, context, streamOptions) => {
const turnState = getTurnState();
const snapshotOptions: AgentHarnessStreamOptions = { ...turnState.streamOptions };
@@ -418,8 +440,8 @@ export class AgentHarness<
}
private createLoopConfig(
getTurnState: () => AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>,
setTurnState: (turnState: AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>) => void,
getTurnState: () => AgentHarnessTurnState<TContext, TSkill, TPromptTemplate, TTool>,
setTurnState: (turnState: AgentHarnessTurnState<TContext, TSkill, TPromptTemplate, TTool>) => void,
): AgentLoopConfig {
const turnState = getTurnState();
return {
@@ -557,7 +579,7 @@ export class AgentHarness<
}
private async executeTurn(
turnState: AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>,
turnState: AgentHarnessTurnState<TContext, TSkill, TPromptTemplate, TTool>,
text: string,
options?: { images?: ImageContent[] },
): Promise<AssistantMessage> {
@@ -584,7 +606,7 @@ export class AgentHarness<
const abortController = new AbortController();
const getTurnState = () => activeTurnState;
const setTurnState = (nextTurnState: AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>) => {
const setTurnState = (nextTurnState: AgentHarnessTurnState<TContext, TSkill, TPromptTemplate, TTool>) => {
activeTurnState = nextTurnState;
};
this.runAbortController = abortController;
+140 -34
View File
@@ -1,4 +1,4 @@
import { spawn } from "node:child_process";
import { type ChildProcess, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { constants, createReadStream } from "node:fs";
import {
@@ -13,9 +13,10 @@ import {
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { homedir, tmpdir } from "node:os";
import { isAbsolute, join, resolve } from "node:path";
import { createInterface } from "node:readline";
import { fileURLToPath } from "node:url";
import {
type ExecutionEnv,
ExecutionError,
@@ -25,11 +26,13 @@ import {
type FileKind,
ok,
type Result,
type ShellExecOptions,
toError,
} from "../types.ts";
const MAX_TIMEOUT_MS = 2_147_483_647;
const MAX_TIMEOUT_SECONDS = MAX_TIMEOUT_MS / 1000;
const EXIT_STDIO_GRACE_MS = 100;
function resolveTimeoutMs(timeout: number | undefined): Result<number | undefined, ExecutionError> {
if (timeout === undefined) return ok(undefined);
@@ -45,7 +48,19 @@ function resolveTimeoutMs(timeout: number | undefined): Result<number | undefine
}
function resolvePath(cwd: string, path: string): string {
return isAbsolute(path) ? path : resolve(cwd, path);
let normalized = path;
if (normalized === "~") {
normalized = homedir();
} else if (normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\"))) {
normalized = join(homedir(), normalized.slice(2));
} else if (normalized.startsWith("file://")) {
try {
normalized = fileURLToPath(normalized);
} catch {
// Keep malformed URLs as ordinary paths so filesystem methods preserve their non-throwing contract.
}
}
return isAbsolute(normalized) ? resolve(normalized) : resolve(cwd, normalized);
}
function fileKindFromStats(stats: {
@@ -197,7 +212,16 @@ async function getShellConfig(customShellPath?: string): Promise<Result<ShellCon
if (bashOnPath) {
return ok(getBashShellConfig(bashOnPath));
}
return err(new ExecutionError("shell_unavailable", "No bash shell found"));
return err(
new ExecutionError(
"shell_unavailable",
`No bash shell found. Options:\n` +
` 1. Install Git for Windows: https://git-scm.com/download/win\n` +
` 2. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` +
" 3. Configure an explicit shellPath\n\n" +
`Searched Git Bash in:\n${candidates.map((path) => ` ${path}`).join("\n")}`,
),
);
}
if (await pathExists("/bin/bash")) {
@@ -210,7 +234,12 @@ async function getShellConfig(customShellPath?: string): Promise<Result<ShellCon
return ok({ shell: "sh", args: ["-c"] });
}
function getShellEnv(baseEnv?: NodeJS.ProcessEnv, extraEnv?: Record<string, string>): NodeJS.ProcessEnv {
function getShellEnv(
baseEnv?: NodeJS.ProcessEnv,
extraEnv?: Record<string, string>,
inheritEnv = true,
): NodeJS.ProcessEnv {
if (!inheritEnv) return { ...extraEnv };
return {
...process.env,
...baseEnv,
@@ -243,10 +272,80 @@ function killProcessTree(pid: number): void {
}
}
function waitForChildProcess(child: ChildProcess): Promise<number | null> {
return new Promise((resolvePromise, reject) => {
let settled = false;
let exited = false;
let exitCode: number | null = null;
let postExitTimer: ReturnType<typeof setTimeout> | undefined;
let stdoutEnded = child.stdout === null;
let stderrEnded = child.stderr === null;
const cleanup = (): void => {
if (postExitTimer) clearTimeout(postExitTimer);
child.removeListener("error", onError);
child.removeListener("exit", onExit);
child.removeListener("close", onClose);
child.stdout?.removeListener("end", onStdoutEnd);
child.stderr?.removeListener("end", onStderrEnd);
child.stdout?.removeListener("data", onData);
child.stderr?.removeListener("data", onData);
};
const finalize = (code: number | null): void => {
if (settled) return;
settled = true;
cleanup();
child.stdout?.destroy();
child.stderr?.destroy();
resolvePromise(code);
};
const maybeFinalizeAfterExit = (): void => {
if (exited && stdoutEnded && stderrEnded) finalize(exitCode);
};
const armIdleTimer = (): void => {
if (postExitTimer) clearTimeout(postExitTimer);
postExitTimer = setTimeout(() => finalize(exitCode), EXIT_STDIO_GRACE_MS);
};
const onData = (): void => {
if (exited && !settled) armIdleTimer();
};
const onStdoutEnd = (): void => {
stdoutEnded = true;
maybeFinalizeAfterExit();
};
const onStderrEnd = (): void => {
stderrEnded = true;
maybeFinalizeAfterExit();
};
const onError = (error: Error): void => {
if (settled) return;
settled = true;
cleanup();
reject(error);
};
const onExit = (code: number | null): void => {
exited = true;
exitCode = code;
maybeFinalizeAfterExit();
if (!settled) armIdleTimer();
};
const onClose = (code: number | null): void => finalize(code);
child.stdout?.once("end", onStdoutEnd);
child.stderr?.once("end", onStderrEnd);
child.stdout?.on("data", onData);
child.stderr?.on("data", onData);
child.once("error", onError);
child.once("exit", onExit);
child.once("close", onClose);
});
}
export class NodeExecutionEnv implements ExecutionEnv {
cwd: string;
private shellPath?: string;
private shellEnv?: NodeJS.ProcessEnv;
private activeChildPids = new Set<number>();
constructor(options: { cwd: string; shellPath?: string; shellEnv?: NodeJS.ProcessEnv }) {
this.cwd = options.cwd;
@@ -264,14 +363,7 @@ export class NodeExecutionEnv implements ExecutionEnv {
async exec(
command: string,
options?: {
cwd?: string;
env?: Record<string, string>;
timeout?: number;
abortSignal?: AbortSignal;
onStdout?: (chunk: string) => void;
onStderr?: (chunk: string) => void;
},
options?: ShellExecOptions,
): Promise<Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>> {
if (options?.abortSignal?.aborted) return err(new ExecutionError("aborted", "aborted"));
const timeoutMsResult = resolveTimeoutMs(options?.timeout);
@@ -281,6 +373,18 @@ export class NodeExecutionEnv implements ExecutionEnv {
const cwd = options?.cwd ? resolvePath(this.cwd, options.cwd) : this.cwd;
const shellConfig = await getShellConfig(this.shellPath);
if (!shellConfig.ok) return shellConfig;
try {
await access(cwd, constants.F_OK);
} catch (error) {
const cause = toError(error);
return err(
new ExecutionError(
"spawn_error",
`Working directory does not exist: ${cwd}\nCannot execute bash commands.`,
cause,
),
);
}
return await new Promise((resolvePromise) => {
let stdout = "";
@@ -300,6 +404,7 @@ export class NodeExecutionEnv implements ExecutionEnv {
const settle = (result: Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>) => {
if (timeoutId) clearTimeout(timeoutId);
if (options?.abortSignal) options.abortSignal.removeEventListener("abort", onAbort);
if (child?.pid) this.activeChildPids.delete(child.pid);
if (settled) return;
settled = true;
resolvePromise(result);
@@ -313,11 +418,12 @@ export class NodeExecutionEnv implements ExecutionEnv {
{
cwd,
detached: process.platform !== "win32",
env: getShellEnv(this.shellEnv, options?.env),
env: getShellEnv(this.shellEnv, options?.env, options?.inheritEnv),
stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "pipe"],
windowsHide: true,
},
);
if (child.pid) this.activeChildPids.add(child.pid);
if (commandFromStdin) {
child.stdin?.on("error", () => {});
child.stdin?.end(command);
@@ -369,25 +475,24 @@ export class NodeExecutionEnv implements ExecutionEnv {
}
});
child.on("error", (error) => {
settle(err(new ExecutionError("spawn_error", error.message, error)));
});
child.on("close", (code) => {
if (callbackError) {
settle(err(callbackError));
return;
}
if (timedOut) {
settle(err(new ExecutionError("timeout", `timeout:${options?.timeout}`)));
return;
}
if (options?.abortSignal?.aborted) {
settle(err(new ExecutionError("aborted", "aborted")));
return;
}
settle(ok({ stdout, stderr, exitCode: code ?? 0 }));
});
void waitForChildProcess(child).then(
(code) => {
if (callbackError) {
settle(err(callbackError));
return;
}
if (timedOut) {
settle(err(new ExecutionError("timeout", `timeout:${options?.timeout}`)));
return;
}
if (options?.abortSignal?.aborted) {
settle(err(new ExecutionError("aborted", "aborted")));
return;
}
settle(ok({ stdout, stderr, exitCode: code ?? 0 }));
},
(error: Error) => settle(err(new ExecutionError("spawn_error", error.message, error))),
);
});
}
@@ -564,6 +669,7 @@ export class NodeExecutionEnv implements ExecutionEnv {
}
async cleanup(): Promise<void> {
// nothing to clean up for the local node implementation
for (const pid of this.activeChildPids) killProcessTree(pid);
this.activeChildPids.clear();
}
}
+161
View File
@@ -0,0 +1,161 @@
import { type Static, Type } from "typebox";
import type { AgentHarnessTool } from "../types.ts";
import { getOrThrow } from "../types.ts";
import { executeShellWithCapture, type ShellCaptureProgress } from "../utils/shell-output.ts";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult } from "../utils/truncate.ts";
import type { ExecutionToolContext } from "./tool-context.ts";
const MAX_TIMEOUT_SECONDS = 2_147_483_647 / 1000;
const BASH_UPDATE_THROTTLE_MS = 100;
const bashSchema = Type.Object({
command: Type.String({ description: "Bash command to execute" }),
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
});
export type BashToolInput = Static<typeof bashSchema>;
export interface BashToolDetails {
truncation?: TruncationResult;
fullOutputPath?: string;
}
export interface BashExecution {
command: string;
cwd: string;
env: Record<string, string>;
inheritEnv: boolean;
}
export type BashPrepare<TContext extends ExecutionToolContext = ExecutionToolContext> = (
execution: BashExecution,
context: TContext,
signal?: AbortSignal,
) => void | Promise<void>;
export interface BashToolOptions<TContext extends ExecutionToolContext = ExecutionToolContext> {
commandPrefix?: string;
prepare?: BashPrepare<TContext>;
}
function validateTimeout(timeout: number | undefined): void {
if (timeout === undefined) return;
if (!Number.isFinite(timeout) || timeout <= 0) {
throw new Error("Invalid timeout: must be a finite number of seconds");
}
if (timeout > MAX_TIMEOUT_SECONDS) {
throw new Error(`Invalid timeout: maximum is ${MAX_TIMEOUT_SECONDS} seconds`);
}
}
export function createBashTool<TContext extends ExecutionToolContext = ExecutionToolContext>(
options?: BashToolOptions<TContext>,
): AgentHarnessTool<TContext, typeof bashSchema, BashToolDetails | undefined> {
return {
name: "bash",
label: "bash",
description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`,
parameters: bashSchema,
async execute(_toolCallId, { command, timeout }, signal, onUpdate, context) {
validateTimeout(timeout);
const { env } = context;
const execution: BashExecution = {
command: options?.commandPrefix ? `${options.commandPrefix}\n${command}` : command,
cwd: env.cwd,
env: {},
inheritEnv: true,
};
await options?.prepare?.(execution, context, signal);
let getLatestProgress: (() => ShellCaptureProgress) | undefined;
let updateTimer: ReturnType<typeof setTimeout> | undefined;
let updateDirty = false;
let lastUpdateAt = 0;
const emitOutputUpdate = (): void => {
if (!onUpdate || !updateDirty || !getLatestProgress) return;
updateDirty = false;
lastUpdateAt = Date.now();
const progress = getLatestProgress();
onUpdate({
content: [{ type: "text", text: progress.output }],
details: {
truncation: progress.truncation.truncated ? progress.truncation : undefined,
fullOutputPath: progress.fullOutputPath,
},
});
};
const clearUpdateTimer = (): void => {
if (!updateTimer) return;
clearTimeout(updateTimer);
updateTimer = undefined;
};
const scheduleOutputUpdate = (): void => {
if (!onUpdate) return;
updateDirty = true;
const delay = BASH_UPDATE_THROTTLE_MS - (Date.now() - lastUpdateAt);
if (delay <= 0) {
clearUpdateTimer();
emitOutputUpdate();
return;
}
updateTimer ??= setTimeout(() => {
updateTimer = undefined;
emitOutputUpdate();
}, delay);
};
onUpdate?.({ content: [], details: undefined });
try {
const capture = getOrThrow(
await executeShellWithCapture(env, execution.command, {
cwd: execution.cwd,
env: execution.env,
inheritEnv: execution.inheritEnv,
timeout,
abortSignal: signal,
returnExecutionErrors: true,
onChunk: (_chunk, getProgress) => {
getLatestProgress = getProgress;
scheduleOutputUpdate();
},
}),
);
clearUpdateTimer();
getLatestProgress = () => capture;
updateDirty = true;
emitOutputUpdate();
let outputText = capture.output;
let details: BashToolDetails | undefined;
if (capture.truncation.truncated) {
details = { truncation: capture.truncation, fullOutputPath: capture.fullOutputPath };
const startLine = capture.truncation.totalLines - capture.truncation.outputLines + 1;
const endLine = capture.truncation.totalLines;
if (capture.truncation.lastLinePartial) {
const lastLineSize = formatSize(capture.lastLineBytes);
outputText += `\n\n[Showing last ${formatSize(capture.truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${capture.fullOutputPath}]`;
} else if (capture.truncation.truncatedBy === "lines") {
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${capture.truncation.totalLines}. Full output: ${capture.fullOutputPath}]`;
} else {
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${capture.truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${capture.fullOutputPath}]`;
}
}
const appendStatus = (status: string): string => `${outputText ? `${outputText}\n\n` : ""}${status}`;
if (capture.cancelled) throw new Error(appendStatus("Command aborted"));
if (capture.executionError?.code === "timeout") {
throw new Error(appendStatus(`Command timed out after ${timeout} seconds`), {
cause: capture.executionError,
});
}
if (capture.executionError) throw capture.executionError;
if (capture.exitCode !== 0 && capture.exitCode !== undefined) {
throw new Error(appendStatus(`Command exited with code ${capture.exitCode}`));
}
return { content: [{ type: "text", text: outputText || "(no output)" }], details };
} finally {
clearUpdateTimer();
}
},
};
}
@@ -0,0 +1,500 @@
/**
* Shared diff computation utilities for the edit and similar tools.
*/
import * as Diff from "diff";
export function detectLineEnding(content: string): "\r\n" | "\n" {
const crlfIdx = content.indexOf("\r\n");
const lfIdx = content.indexOf("\n");
if (lfIdx === -1) return "\n";
if (crlfIdx === -1) return "\n";
return crlfIdx < lfIdx ? "\r\n" : "\n";
}
export function normalizeToLF(text: string): string {
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}
export function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string {
return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
}
/**
* Normalize text for fuzzy matching. Applies progressive transformations:
* - Strip trailing whitespace from each line
* - Normalize smart quotes to ASCII equivalents
* - Normalize Unicode dashes/hyphens to ASCII hyphen
* - Normalize special Unicode spaces to regular space
*/
export function normalizeForFuzzyMatch(text: string): string {
return (
text
.normalize("NFKC")
// Strip trailing whitespace per line
.split("\n")
.map((line) => line.trimEnd())
.join("\n")
// Smart single quotes → '
.replace(/[\u2018\u2019\u201A\u201B]/g, "'")
// Smart double quotes → "
.replace(/[\u201C\u201D\u201E\u201F]/g, '"')
// Various dashes/hyphens → -
// U+2010 hyphen, U+2011 non-breaking hyphen, U+2012 figure dash,
// U+2013 en-dash, U+2014 em-dash, U+2015 horizontal bar, U+2212 minus
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-")
// Special spaces → regular space
// U+00A0 NBSP, U+2002-U+200A various spaces, U+202F narrow NBSP,
// U+205F medium math space, U+3000 ideographic space
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ")
);
}
function splitLinesWithEndings(content: string): string[] {
return content.match(/[^\n]*\n|[^\n]+/g) ?? [];
}
interface LineSpan {
start: number;
end: number;
}
interface MatchedEdit {
editIndex: number;
matchIndex: number;
matchLength: number;
newText: string;
}
type TextReplacement = Pick<MatchedEdit, "matchIndex" | "matchLength" | "newText">;
function getLineSpans(content: string): LineSpan[] {
let offset = 0;
return splitLinesWithEndings(content).map((line) => {
const span = { start: offset, end: offset + line.length };
offset = span.end;
return span;
});
}
function getReplacementLineRange(lines: LineSpan[], replacement: TextReplacement) {
const replacementStart = replacement.matchIndex;
const replacementEnd = replacement.matchIndex + replacement.matchLength;
let startLine = -1;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (replacementStart >= line.start && replacementStart < line.end) {
startLine = i;
break;
}
}
if (startLine === -1) {
throw new Error("Replacement range is outside the base content.");
}
let endLine = startLine;
while (endLine < lines.length && lines[endLine].end < replacementEnd) {
endLine++;
}
if (endLine >= lines.length) {
throw new Error("Replacement range is outside the base content.");
}
return { startLine, endLine: endLine + 1 };
}
function applyReplacements(content: string, replacements: TextReplacement[], offset = 0): string {
let result = content;
for (let i = replacements.length - 1; i >= 0; i--) {
const replacement = replacements[i];
const matchIndex = replacement.matchIndex - offset;
result =
result.substring(0, matchIndex) + replacement.newText + result.substring(matchIndex + replacement.matchLength);
}
return result;
}
/**
* Apply replacements matched against `baseContent` to `originalContent` while
* preserving unchanged line blocks from the original.
*
* This is useful when `baseContent` is a normalized view of the original. Each
* replacement is widened to the lines it actually touches, those touched lines
* are rewritten from the normalized base, and all other lines are copied back
* from `originalContent`. The actual replacement ranges drive preservation so
* duplicate normalized lines cannot be aligned to the wrong occurrence.
*/
export function applyReplacementsPreservingUnchangedLines(
originalContent: string,
baseContent: string,
replacements: TextReplacement[],
): string {
const originalLines = splitLinesWithEndings(originalContent);
const baseLines = getLineSpans(baseContent);
if (originalLines.length !== baseLines.length) {
throw new Error("Cannot preserve unchanged lines because the base content has a different line count.");
}
const groups: Array<{ startLine: number; endLine: number; replacements: TextReplacement[] }> = [];
const sortedReplacements = [...replacements].sort((a, b) => a.matchIndex - b.matchIndex);
for (const replacement of sortedReplacements) {
const range = getReplacementLineRange(baseLines, replacement);
const current = groups[groups.length - 1];
if (current && range.startLine < current.endLine) {
current.endLine = Math.max(current.endLine, range.endLine);
current.replacements.push(replacement);
continue;
}
groups.push({ ...range, replacements: [replacement] });
}
let originalLineIndex = 0;
let result = "";
for (const group of groups) {
result += originalLines.slice(originalLineIndex, group.startLine).join("");
const groupStartOffset = baseLines[group.startLine].start;
const groupEndOffset = baseLines[group.endLine - 1].end;
result += applyReplacements(
baseContent.slice(groupStartOffset, groupEndOffset),
group.replacements,
groupStartOffset,
);
originalLineIndex = group.endLine;
}
result += originalLines.slice(originalLineIndex).join("");
return result;
}
export interface FuzzyMatchResult {
/** Whether a match was found */
found: boolean;
/** The index where the match starts (in the content that should be used for replacement) */
index: number;
/** Length of the matched text */
matchLength: number;
/** Whether fuzzy matching was used (false = exact match) */
usedFuzzyMatch: boolean;
/**
* The content to use for replacement operations.
* When exact match: original content. When fuzzy match: normalized content.
*/
contentForReplacement: string;
}
export interface Edit {
oldText: string;
newText: string;
}
export interface AppliedEditsResult {
baseContent: string;
newContent: string;
}
/**
* Find oldText in content, trying exact match first, then fuzzy match.
* When fuzzy matching is used, the returned contentForReplacement is the
* fuzzy-normalized version of the content (trailing whitespace stripped,
* Unicode quotes/dashes normalized to ASCII).
*/
export function fuzzyFindText(content: string, oldText: string): FuzzyMatchResult {
// Try exact match first
const exactIndex = content.indexOf(oldText);
if (exactIndex !== -1) {
return {
found: true,
index: exactIndex,
matchLength: oldText.length,
usedFuzzyMatch: false,
contentForReplacement: content,
};
}
// Try fuzzy match - work entirely in normalized space
const fuzzyContent = normalizeForFuzzyMatch(content);
const fuzzyOldText = normalizeForFuzzyMatch(oldText);
const fuzzyIndex = fuzzyContent.indexOf(fuzzyOldText);
if (fuzzyIndex === -1) {
return {
found: false,
index: -1,
matchLength: 0,
usedFuzzyMatch: false,
contentForReplacement: content,
};
}
// When fuzzy matching, return offsets in normalized space. Callers can use
// the normalized content to compute replacements, then decide how much of
// that normalized output should be written back.
return {
found: true,
index: fuzzyIndex,
matchLength: fuzzyOldText.length,
usedFuzzyMatch: true,
contentForReplacement: fuzzyContent,
};
}
/** Strip UTF-8 BOM if present, return both the BOM (if any) and the text without it */
export function stripBom(content: string): { bom: string; text: string } {
return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content };
}
function countOccurrences(content: string, oldText: string): number {
const fuzzyContent = normalizeForFuzzyMatch(content);
const fuzzyOldText = normalizeForFuzzyMatch(oldText);
return fuzzyContent.split(fuzzyOldText).length - 1;
}
function getNotFoundError(path: string, editIndex: number, totalEdits: number): Error {
if (totalEdits === 1) {
return new Error(
`Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`,
);
}
return new Error(
`Could not find edits[${editIndex}] in ${path}. The oldText must match exactly including all whitespace and newlines.`,
);
}
function getDuplicateError(path: string, editIndex: number, totalEdits: number, occurrences: number): Error {
if (totalEdits === 1) {
return new Error(
`Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,
);
}
return new Error(
`Found ${occurrences} occurrences of edits[${editIndex}] in ${path}. Each oldText must be unique. Please provide more context to make it unique.`,
);
}
function getEmptyOldTextError(path: string, editIndex: number, totalEdits: number): Error {
if (totalEdits === 1) {
return new Error(`oldText must not be empty in ${path}.`);
}
return new Error(`edits[${editIndex}].oldText must not be empty in ${path}.`);
}
function getNoChangeError(path: string, totalEdits: number): Error {
if (totalEdits === 1) {
return new Error(
`No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`,
);
}
return new Error(`No changes made to ${path}. The replacements produced identical content.`);
}
/**
* Apply one or more exact-text replacements to LF-normalized content.
*
* All edits are matched against the same original content. Replacements are
* then applied in reverse order so offsets remain stable. If any edit needs
* fuzzy matching, the operation runs in fuzzy-normalized content space and then
* overlays those line-level changes onto the original content so unchanged line
* blocks keep their original bytes.
*/
export function applyEditsToNormalizedContent(
normalizedContent: string,
edits: Edit[],
path: string,
): AppliedEditsResult {
const normalizedEdits = edits.map((edit) => ({
oldText: normalizeToLF(edit.oldText),
newText: normalizeToLF(edit.newText),
}));
for (let i = 0; i < normalizedEdits.length; i++) {
if (normalizedEdits[i].oldText.length === 0) {
throw getEmptyOldTextError(path, i, normalizedEdits.length);
}
}
const initialMatches = normalizedEdits.map((edit) => fuzzyFindText(normalizedContent, edit.oldText));
const usedFuzzyMatch = initialMatches.some((match) => match.usedFuzzyMatch);
const replacementBaseContent = usedFuzzyMatch ? normalizeForFuzzyMatch(normalizedContent) : normalizedContent;
const matchedEdits: MatchedEdit[] = [];
for (let i = 0; i < normalizedEdits.length; i++) {
const edit = normalizedEdits[i];
const matchResult = fuzzyFindText(replacementBaseContent, edit.oldText);
if (!matchResult.found) {
throw getNotFoundError(path, i, normalizedEdits.length);
}
const occurrences = countOccurrences(replacementBaseContent, edit.oldText);
if (occurrences > 1) {
throw getDuplicateError(path, i, normalizedEdits.length, occurrences);
}
matchedEdits.push({
editIndex: i,
matchIndex: matchResult.index,
matchLength: matchResult.matchLength,
newText: edit.newText,
});
}
matchedEdits.sort((a, b) => a.matchIndex - b.matchIndex);
for (let i = 1; i < matchedEdits.length; i++) {
const previous = matchedEdits[i - 1];
const current = matchedEdits[i];
if (previous.matchIndex + previous.matchLength > current.matchIndex) {
throw new Error(
`edits[${previous.editIndex}] and edits[${current.editIndex}] overlap in ${path}. Merge them into one edit or target disjoint regions.`,
);
}
}
const baseContent = normalizedContent;
const newContent = usedFuzzyMatch
? applyReplacementsPreservingUnchangedLines(normalizedContent, replacementBaseContent, matchedEdits)
: applyReplacements(replacementBaseContent, matchedEdits);
if (baseContent === newContent) {
throw getNoChangeError(path, normalizedEdits.length);
}
return { baseContent, newContent };
}
/** Generate a standard unified patch. */
export function generateUnifiedPatch(path: string, oldContent: string, newContent: string, contextLines = 4): string {
return Diff.createTwoFilesPatch(path, path, oldContent, newContent, undefined, undefined, {
context: contextLines,
headerOptions: Diff.FILE_HEADERS_ONLY,
});
}
/**
* Generate a display-oriented diff string with line numbers and context.
* Returns both the diff string and the first changed line number (in the new file).
*/
export function generateDiffString(
oldContent: string,
newContent: string,
contextLines = 4,
): { diff: string; firstChangedLine: number | undefined } {
const parts = Diff.diffLines(oldContent, newContent);
const output: string[] = [];
const oldLines = oldContent.split("\n");
const newLines = newContent.split("\n");
const maxLineNum = Math.max(oldLines.length, newLines.length);
const lineNumWidth = String(maxLineNum).length;
let oldLineNum = 1;
let newLineNum = 1;
let lastWasChange = false;
let firstChangedLine: number | undefined;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const raw = part.value.split("\n");
if (raw[raw.length - 1] === "") {
raw.pop();
}
if (part.added || part.removed) {
// Capture the first changed line (in the new file)
if (firstChangedLine === undefined) {
firstChangedLine = newLineNum;
}
// Show the change
for (const line of raw) {
if (part.added) {
const lineNum = String(newLineNum).padStart(lineNumWidth, " ");
output.push(`+${lineNum} ${line}`);
newLineNum++;
} else {
// removed
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
output.push(`-${lineNum} ${line}`);
oldLineNum++;
}
}
lastWasChange = true;
} else {
// Context lines - only show a few before/after changes
const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
const hasLeadingChange = lastWasChange;
const hasTrailingChange = nextPartIsChange;
if (hasLeadingChange && hasTrailingChange) {
if (raw.length <= contextLines * 2) {
for (const line of raw) {
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
output.push(` ${lineNum} ${line}`);
oldLineNum++;
newLineNum++;
}
} else {
const leadingLines = raw.slice(0, contextLines);
const trailingLines = raw.slice(raw.length - contextLines);
const skippedLines = raw.length - leadingLines.length - trailingLines.length;
for (const line of leadingLines) {
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
output.push(` ${lineNum} ${line}`);
oldLineNum++;
newLineNum++;
}
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
oldLineNum += skippedLines;
newLineNum += skippedLines;
for (const line of trailingLines) {
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
output.push(` ${lineNum} ${line}`);
oldLineNum++;
newLineNum++;
}
}
} else if (hasLeadingChange) {
const shownLines = raw.slice(0, contextLines);
const skippedLines = raw.length - shownLines.length;
for (const line of shownLines) {
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
output.push(` ${lineNum} ${line}`);
oldLineNum++;
newLineNum++;
}
if (skippedLines > 0) {
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
oldLineNum += skippedLines;
newLineNum += skippedLines;
}
} else if (hasTrailingChange) {
const skippedLines = Math.max(0, raw.length - contextLines);
if (skippedLines > 0) {
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
oldLineNum += skippedLines;
newLineNum += skippedLines;
}
for (const line of raw.slice(skippedLines)) {
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
output.push(` ${lineNum} ${line}`);
oldLineNum++;
newLineNum++;
}
} else {
// Skip these context lines entirely
oldLineNum += raw.length;
newLineNum += raw.length;
}
lastWasChange = false;
}
}
return { diff: output.join("\n"), firstChangedLine };
}
+127
View File
@@ -0,0 +1,127 @@
import { type Static, Type } from "typebox";
import type { AgentHarnessTool, FileError } from "../types.ts";
import {
applyEditsToNormalizedContent,
detectLineEnding,
type Edit,
generateDiffString,
generateUnifiedPatch,
normalizeToLF,
restoreLineEndings,
stripBom,
} from "./edit-diff.ts";
import { withFileMutationQueue } from "./file-mutation-queue.ts";
import { resolveToolPath } from "./path-utils.ts";
import type { ExecutionToolContext } from "./tool-context.ts";
const replaceEditSchema = Type.Object(
{
oldText: Type.String({
description:
"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.",
}),
newText: Type.String({ description: "Replacement text for this targeted edit." }),
},
{},
);
const editSchema = Type.Object(
{
path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
edits: Type.Array(replaceEditSchema, {
description:
"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.",
}),
},
{},
);
export type EditToolInput = Static<typeof editSchema>;
type LegacyEditToolInput = EditToolInput & { oldText?: unknown; newText?: unknown };
export interface EditToolDetails {
diff: string;
patch: string;
firstChangedLine?: number;
}
function prepareEditArguments(input: unknown): EditToolInput {
if (!input || typeof input !== "object") return input as EditToolInput;
const args = input as Record<string, unknown>;
if (typeof args.edits === "string") {
try {
const parsed: unknown = JSON.parse(args.edits);
if (Array.isArray(parsed)) args.edits = parsed;
} catch {}
}
const legacy = args as LegacyEditToolInput;
if (typeof legacy.oldText !== "string" || typeof legacy.newText !== "string") return args as EditToolInput;
const edits = Array.isArray(legacy.edits) ? [...legacy.edits] : [];
edits.push({ oldText: legacy.oldText, newText: legacy.newText });
const { oldText: _oldText, newText: _newText, ...rest } = legacy;
return { ...rest, edits } as EditToolInput;
}
function validateEditInput(input: EditToolInput): { path: string; edits: Edit[] } {
if (!Array.isArray(input.edits) || input.edits.length === 0) {
throw new Error("Edit tool input is invalid. edits must contain at least one replacement.");
}
return { path: input.path, edits: input.edits };
}
function editAccessError(path: string, error: FileError): Error {
return new Error(`Could not edit file: ${path}. Error code: ${error.code}.`, { cause: error });
}
export function createEditTool<TContext extends ExecutionToolContext = ExecutionToolContext>(): AgentHarnessTool<
TContext,
typeof editSchema,
EditToolDetails | undefined
> {
return {
name: "edit",
label: "edit",
description:
"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.",
parameters: editSchema,
prepareArguments: prepareEditArguments,
async execute(_toolCallId, input, signal, _onUpdate, { env }) {
const { path, edits } = validateEditInput(input);
const absolutePath = await resolveToolPath(env, path, signal);
return withFileMutationQueue(env, absolutePath, async () => {
if (signal?.aborted) throw new Error("Operation aborted");
const info = await env.fileInfo(absolutePath, signal);
if (!info.ok) throw editAccessError(path, info.error);
if (info.value.kind !== "file" && info.value.kind !== "symlink") {
throw new Error(`Could not edit file: ${path}. Path is not a file.`);
}
const readResult = await env.readTextFile(absolutePath, signal);
if (!readResult.ok) throw editAccessError(path, readResult.error);
if (signal?.aborted) throw new Error("Operation aborted");
const { bom, text: content } = stripBom(readResult.value);
const originalEnding = detectLineEnding(content);
const normalizedContent = normalizeToLF(content);
const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path);
if (signal?.aborted) throw new Error("Operation aborted");
const finalContent = bom + restoreLineEndings(newContent, originalEnding);
const writeResult = await env.writeFile(absolutePath, finalContent, signal);
if (!writeResult.ok) throw editAccessError(path, writeResult.error);
if (signal?.aborted) throw new Error("Operation aborted");
const diffResult = generateDiffString(baseContent, newContent);
return {
content: [{ type: "text", text: `Successfully replaced ${edits.length} block(s) in ${path}.` }],
details: {
diff: diffResult.diff,
patch: generateUnifiedPatch(path, baseContent, newContent),
firstChangedLine: diffResult.firstChangedLine,
},
};
});
},
};
}
@@ -0,0 +1,56 @@
import type { ExecutionEnv } from "../types.ts";
import { getOrThrow } from "../types.ts";
type MutationQueueState = {
queues: Map<string, Promise<void>>;
registration: Promise<void>;
};
const states = new WeakMap<ExecutionEnv, MutationQueueState>();
function getState(env: ExecutionEnv): MutationQueueState {
let state = states.get(env);
if (!state) {
state = { queues: new Map(), registration: Promise.resolve() };
states.set(env, state);
}
return state;
}
async function getMutationQueueKey(env: ExecutionEnv, path: string): Promise<string> {
const absolutePath = getOrThrow(await env.absolutePath(path));
const canonicalPath = await env.canonicalPath(absolutePath);
if (canonicalPath.ok) return canonicalPath.value;
if (canonicalPath.error.code === "not_found" || canonicalPath.error.code === "not_supported") return absolutePath;
throw canonicalPath.error;
}
/** Serialize file mutations targeting the same environment and canonical path. */
export async function withFileMutationQueue<T>(env: ExecutionEnv, path: string, fn: () => Promise<T>): Promise<T> {
const state = getState(env);
const registration = state.registration.then(async () => {
const key = await getMutationQueueKey(env, path);
const currentQueue = state.queues.get(key) ?? Promise.resolve();
let releaseNext = () => {};
const nextQueue = new Promise<void>((resolve) => {
releaseNext = resolve;
});
const chainedQueue = currentQueue.then(() => nextQueue);
state.queues.set(key, chainedQueue);
return { key, currentQueue, chainedQueue, releaseNext };
});
state.registration = registration.then(
() => undefined,
() => undefined,
);
const { key, currentQueue, chainedQueue, releaseNext } = await registration;
await currentQueue;
try {
return await fn();
} finally {
releaseNext();
if (state.queues.get(key) === chainedQueue) state.queues.delete(key);
}
}
+104
View File
@@ -0,0 +1,104 @@
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
export function detectSupportedImageMimeType(buffer: Uint8Array): string | undefined {
if (startsWith(buffer, [0xff, 0xd8, 0xff])) return buffer[3] === 0xf7 ? undefined : "image/jpeg";
if (startsWith(buffer, PNG_SIGNATURE)) return isPng(buffer) && !isAnimatedPng(buffer) ? "image/png" : undefined;
if (startsWithAscii(buffer, 0, "GIF")) return "image/gif";
if (startsWithAscii(buffer, 0, "RIFF") && startsWithAscii(buffer, 8, "WEBP")) return "image/webp";
if (startsWithAscii(buffer, 0, "BM") && isBmp(buffer)) return "image/bmp";
return undefined;
}
export function encodeBase64(bytes: Uint8Array): string {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let output = "";
for (let index = 0; index < bytes.length; index += 3) {
const first = bytes[index] ?? 0;
const second = bytes[index + 1];
const third = bytes[index + 2];
output += alphabet[first >> 2];
output += alphabet[((first & 0x03) << 4) | ((second ?? 0) >> 4)];
output += second === undefined ? "=" : alphabet[((second & 0x0f) << 2) | ((third ?? 0) >> 6)];
output += third === undefined ? "=" : alphabet[third & 0x3f];
}
return output;
}
function isPng(buffer: Uint8Array): boolean {
return (
buffer.length >= 16 && readUint32BE(buffer, PNG_SIGNATURE.length) === 13 && startsWithAscii(buffer, 12, "IHDR")
);
}
function isAnimatedPng(buffer: Uint8Array): boolean {
let offset = PNG_SIGNATURE.length;
while (offset + 8 <= buffer.length) {
const chunkLength = readUint32BE(buffer, offset);
const chunkTypeOffset = offset + 4;
if (startsWithAscii(buffer, chunkTypeOffset, "acTL")) return true;
if (startsWithAscii(buffer, chunkTypeOffset, "IDAT")) return false;
const nextOffset = offset + 8 + chunkLength + 4;
if (nextOffset <= offset || nextOffset > buffer.length) return false;
offset = nextOffset;
}
return false;
}
function isBmp(buffer: Uint8Array): boolean {
if (buffer.length < 26) return false;
const declaredFileSize = readUint32LE(buffer, 2);
const pixelDataOffset = readUint32LE(buffer, 10);
const dibHeaderSize = readUint32LE(buffer, 14);
if (declaredFileSize !== 0 && declaredFileSize < 26) return false;
if (pixelDataOffset < 14 + dibHeaderSize) return false;
if (declaredFileSize !== 0 && pixelDataOffset >= declaredFileSize) return false;
let colorPlanes: number;
let bitsPerPixel: number;
if (dibHeaderSize === 12) {
colorPlanes = readUint16LE(buffer, 22);
bitsPerPixel = readUint16LE(buffer, 24);
} else if (dibHeaderSize >= 40 && dibHeaderSize <= 124) {
if (buffer.length < 30) return false;
colorPlanes = readUint16LE(buffer, 26);
bitsPerPixel = readUint16LE(buffer, 28);
} else {
return false;
}
return colorPlanes === 1 && [1, 4, 8, 16, 24, 32].includes(bitsPerPixel);
}
function readUint16LE(buffer: Uint8Array, offset: number): number {
return (buffer[offset] ?? 0) + ((buffer[offset + 1] ?? 0) << 8);
}
function readUint32BE(buffer: Uint8Array, offset: number): number {
return (
(buffer[offset] ?? 0) * 0x1000000 +
((buffer[offset + 1] ?? 0) << 16) +
((buffer[offset + 2] ?? 0) << 8) +
(buffer[offset + 3] ?? 0)
);
}
function readUint32LE(buffer: Uint8Array, offset: number): number {
return (
(buffer[offset] ?? 0) +
((buffer[offset + 1] ?? 0) << 8) +
((buffer[offset + 2] ?? 0) << 16) +
(buffer[offset + 3] ?? 0) * 0x1000000
);
}
function startsWith(buffer: Uint8Array, bytes: number[]): boolean {
if (buffer.length < bytes.length) return false;
return bytes.every((byte, index) => buffer[index] === byte);
}
function startsWithAscii(buffer: Uint8Array, offset: number, text: string): boolean {
if (buffer.length < offset + text.length) return false;
for (let index = 0; index < text.length; index++) {
if (buffer[offset + index] !== text.charCodeAt(index)) return false;
}
return true;
}
+23
View File
@@ -0,0 +1,23 @@
export {
type BashExecution,
type BashPrepare,
type BashToolDetails,
type BashToolInput,
type BashToolOptions,
createBashTool,
} from "./bash.ts";
export {
createEditTool,
type EditToolDetails,
type EditToolInput,
} from "./edit.ts";
export {
createReadTool,
type ReadImageProcessor,
type ReadImageProcessorResult,
type ReadToolDetails,
type ReadToolInput,
type ReadToolOptions,
} from "./read.ts";
export type { ExecutionToolContext } from "./tool-context.ts";
export { createWriteTool, type WriteToolInput } from "./write.ts";
@@ -0,0 +1,30 @@
import type { ExecutionEnv } from "../types.ts";
import { getOrThrow } from "../types.ts";
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
const NARROW_NO_BREAK_SPACE = "\u202F";
function normalizeToolPath(path: string): string {
const normalized = path.replace(UNICODE_SPACES, " ");
return normalized.startsWith("@") ? normalized.slice(1) : normalized;
}
export async function resolveToolPath(env: ExecutionEnv, path: string, signal?: AbortSignal): Promise<string> {
return getOrThrow(await env.absolutePath(normalizeToolPath(path), signal));
}
export async function resolveReadToolPath(env: ExecutionEnv, path: string, signal?: AbortSignal): Promise<string> {
const resolved = await resolveToolPath(env, path, signal);
const variants = [
resolved,
resolved.replace(/ (AM|PM)\./gi, `${NARROW_NO_BREAK_SPACE}$1.`),
resolved.normalize("NFD"),
resolved.replace(/'/g, "\u2019"),
resolved.normalize("NFD").replace(/'/g, "\u2019"),
];
for (const variant of new Set(variants)) {
if (getOrThrow(await env.exists(variant, signal))) return variant;
}
return resolved;
}
+144
View File
@@ -0,0 +1,144 @@
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
import { type Static, Type } from "typebox";
import type { AgentHarnessTool } from "../types.ts";
import { getOrThrow } from "../types.ts";
import {
DEFAULT_MAX_BYTES,
DEFAULT_MAX_LINES,
formatSize,
type TruncationResult,
truncateHead,
} from "../utils/truncate.ts";
import { detectSupportedImageMimeType, encodeBase64 } from "./image.ts";
import { resolveReadToolPath } from "./path-utils.ts";
import type { ExecutionToolContext } from "./tool-context.ts";
const readSchema = Type.Object({
path: Type.String({ description: "Path to the file to read (relative or absolute)" }),
offset: Type.Optional(Type.Number({ description: "Line number to start reading from (1-indexed)" })),
limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),
});
export type ReadToolInput = Static<typeof readSchema>;
export interface ReadToolDetails {
truncation?: TruncationResult;
}
export type ReadImageProcessorResult =
| { ok: true; data: string; mimeType: string; hints: string[] }
| { ok: false; message: string };
export type ReadImageProcessor = (
bytes: Uint8Array,
mimeType: string,
options: { autoResizeImages: boolean },
) => Promise<ReadImageProcessorResult>;
export interface ReadToolOptions {
/** Whether an injected image processor should resize images. Default: true. */
autoResizeImages?: boolean;
/** Optional image conversion/resizing implementation. */
imageProcessor?: ReadImageProcessor;
}
export function createReadTool<TContext extends ExecutionToolContext = ExecutionToolContext>(
options?: ReadToolOptions,
): AgentHarnessTool<TContext, typeof readSchema, ReadToolDetails | undefined> {
return {
name: "read",
label: "read",
description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`,
parameters: readSchema,
async execute(_toolCallId, { path, offset, limit }, signal, _onUpdate, { env }) {
const absolutePath = await resolveReadToolPath(env, path, signal);
const bytes = getOrThrow(await env.readBinaryFile(absolutePath, signal));
const mimeType = detectSupportedImageMimeType(bytes);
if (mimeType) {
if (options?.imageProcessor) {
const processed = await options.imageProcessor(bytes, mimeType, {
autoResizeImages: options.autoResizeImages ?? true,
});
if (!processed.ok) {
return {
content: [{ type: "text", text: `Read image file [${mimeType}]\n${processed.message}` }],
details: undefined,
};
}
const hints = processed.hints.length > 0 ? `\n${processed.hints.join("\n")}` : "";
return {
content: [
{ type: "text", text: `Read image file [${processed.mimeType}]${hints}` },
{ type: "image", data: processed.data, mimeType: processed.mimeType },
] satisfies Array<TextContent | ImageContent>,
details: undefined,
};
}
if (mimeType === "image/bmp") {
return {
content: [
{
type: "text",
text: "Read image file [image/bmp]\n[Image omitted: configure an imageProcessor to convert BMP images.]",
},
],
details: undefined,
};
}
return {
content: [
{ type: "text", text: `Read image file [${mimeType}]` },
{ type: "image", data: encodeBase64(bytes), mimeType },
] satisfies Array<TextContent | ImageContent>,
details: undefined,
};
}
const textContent = new TextDecoder().decode(bytes);
const allLines = textContent.split("\n");
const totalFileLines = allLines.length;
const startLine = offset ? Math.max(0, offset - 1) : 0;
const startLineDisplay = startLine + 1;
if (startLine >= allLines.length) {
throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`);
}
let selectedContent: string;
let userLimitedLines: number | undefined;
if (limit !== undefined) {
const endLine = Math.min(startLine + limit, allLines.length);
selectedContent = allLines.slice(startLine, endLine).join("\n");
userLimitedLines = endLine - startLine;
} else {
selectedContent = allLines.slice(startLine).join("\n");
}
const truncation = truncateHead(selectedContent);
let outputText: string;
let details: ReadToolDetails | undefined;
if (truncation.firstLineExceedsLimit) {
const firstLineSize = formatSize(new TextEncoder().encode(allLines[startLine]).byteLength);
outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`;
details = { truncation };
} else if (truncation.truncated) {
const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
const nextOffset = endLineDisplay + 1;
outputText = truncation.content;
if (truncation.truncatedBy === "lines") {
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`;
} else {
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`;
}
details = { truncation };
} else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {
const remaining = allLines.length - (startLine + userLimitedLines);
const nextOffset = startLine + userLimitedLines + 1;
outputText = `${truncation.content}\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
} else {
outputText = truncation.content;
}
return { content: [{ type: "text", text: outputText }], details };
},
};
}
@@ -0,0 +1,6 @@
import type { ExecutionEnv } from "../types.ts";
/** Filesystem and shell context required by the built-in execution tools. */
export interface ExecutionToolContext {
env: ExecutionEnv;
}
+39
View File
@@ -0,0 +1,39 @@
import { type Static, Type } from "typebox";
import type { AgentHarnessTool } from "../types.ts";
import { getOrThrow } from "../types.ts";
import { withFileMutationQueue } from "./file-mutation-queue.ts";
import { resolveToolPath } from "./path-utils.ts";
import type { ExecutionToolContext } from "./tool-context.ts";
const writeSchema = Type.Object({
path: Type.String({ description: "Path to the file to write (relative or absolute)" }),
content: Type.String({ description: "Content to write to the file" }),
});
export type WriteToolInput = Static<typeof writeSchema>;
export function createWriteTool<TContext extends ExecutionToolContext = ExecutionToolContext>(): AgentHarnessTool<
TContext,
typeof writeSchema,
undefined
> {
return {
name: "write",
label: "write",
description:
"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
parameters: writeSchema,
async execute(_toolCallId, { path, content }, signal, _onUpdate, { env }) {
const absolutePath = await resolveToolPath(env, path, signal);
return withFileMutationQueue(env, absolutePath, async () => {
if (signal?.aborted) throw new Error("Operation aborted");
getOrThrow(await env.writeFile(absolutePath, content, signal));
if (signal?.aborted) throw new Error("Operation aborted");
return {
content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }],
details: undefined,
};
});
},
};
}
+69 -15
View File
@@ -8,7 +8,16 @@ import type {
Transport,
Usage,
} from "@earendil-works/pi-ai";
import type { AgentEvent, AgentMessage, AgentTool, QueueMode, ThinkingLevel } from "../index.ts";
import type { Static, TSchema } from "typebox";
import type {
AgentEvent,
AgentMessage,
AgentTool,
AgentToolResult,
AgentToolUpdateCallback,
QueueMode,
ThinkingLevel,
} from "../index.ts";
import type { Session } from "./session/session.ts";
/** Result of a fallible operation. Expected failures are returned as `ok: false` instead of thrown. */
@@ -86,6 +95,27 @@ export interface AgentHarnessResources<
skills?: TSkill[];
}
/** Tool definition executed by an {@link AgentHarness} with an application-defined context. */
export type AgentHarnessTool<
TContext extends object | undefined,
TParameters extends TSchema = TSchema,
TDetails = unknown,
> = Omit<AgentTool<TParameters, TDetails>, "execute"> & {
/** Execute the tool call with the context resolved for the current turn snapshot. */
execute(
toolCallId: string,
params: Static<TParameters>,
signal: AbortSignal | undefined,
onUpdate: AgentToolUpdateCallback<TDetails> | undefined,
context: TContext,
): Promise<AgentToolResult<TDetails>>;
};
/** Static tool context or zero-argument provider resolved for each turn snapshot. */
export type AgentHarnessToolContextSource<TContext extends object | undefined> =
| TContext
| (() => TContext | Promise<TContext>);
/** Curated provider request options owned by the harness and snapshotted per turn. */
export interface AgentHarnessStreamOptions {
/** Preferred transport forwarded to the stream function. */
@@ -314,8 +344,10 @@ export interface FileSystem {
export interface ShellExecOptions {
/** Working directory for the command. Relative paths are resolved against {@link ExecutionEnv.cwd}. Defaults to {@link ExecutionEnv.cwd}. */
cwd?: string;
/** Additional environment variables for the command. Values override the environment defaults. Defaults to no overrides. */
/** Environment variables for the command. Values override inherited defaults when `inheritEnv` is true. */
env?: Record<string, string>;
/** Whether to inherit the execution environment's default variables. Defaults to true. */
inheritEnv?: boolean;
/** Timeout in seconds. Implementations should return a timeout error when the command exceeds this duration. Defaults to no timeout. */
timeout?: number;
/** Abort signal used to terminate the command. Defaults to no abort signal. */
@@ -861,12 +893,27 @@ export interface BranchSummaryResult {
modifiedFiles: string[];
}
export interface AgentHarnessOptions<
export type AgentHarnessSystemPrompt<
TContext extends object | undefined = undefined,
TSkill extends Skill = Skill,
TPromptTemplate extends PromptTemplate = PromptTemplate,
TTool extends AgentTool = AgentTool,
TTool extends AgentHarnessTool<TContext> = AgentHarnessTool<TContext>,
> =
| string
| ((context: {
session: Session;
model: Model<any>;
thinkingLevel: ThinkingLevel;
activeTools: TTool[];
resources: AgentHarnessResources<TSkill, TPromptTemplate>;
}) => string | Promise<string>);
interface AgentHarnessOptionsBase<
TContext extends object | undefined,
TSkill extends Skill,
TPromptTemplate extends PromptTemplate,
TTool extends AgentHarnessTool<TContext>,
> {
env: ExecutionEnv;
session: Session;
/**
* Provider collection used for all model requests (turn streaming,
@@ -880,16 +927,7 @@ export interface AgentHarnessOptions<
* Applications own loading/reloading resources and should call `setResources()` with new values.
*/
resources?: AgentHarnessResources<TSkill, TPromptTemplate>;
systemPrompt?:
| string
| ((context: {
env: ExecutionEnv;
session: Session;
model: Model<any>;
thinkingLevel: ThinkingLevel;
activeTools: TTool[];
resources: AgentHarnessResources<TSkill, TPromptTemplate>;
}) => string | Promise<string>);
systemPrompt?: AgentHarnessSystemPrompt<TContext, TSkill, TPromptTemplate, TTool>;
/** Curated stream/provider request options. Snapshotted at turn start. */
streamOptions?: AgentHarnessStreamOptions;
/** Optional retry policy for generated compaction and branch-summary requests. */
@@ -901,4 +939,20 @@ export interface AgentHarnessOptions<
followUpMode?: QueueMode;
}
export type AgentHarnessOptions<
TContext extends object | undefined = undefined,
TSkill extends Skill = Skill,
TPromptTemplate extends PromptTemplate = PromptTemplate,
TTool extends AgentHarnessTool<TContext> = AgentHarnessTool<TContext>,
> = AgentHarnessOptionsBase<TContext, TSkill, TPromptTemplate, TTool> &
([TContext] extends [undefined]
? {
/** Context-free harnesses do not need a tool context. */
toolContext?: undefined;
}
: {
/** Static context or zero-argument context provider resolved for each turn snapshot. */
toolContext: AgentHarnessToolContextSource<TContext>;
});
export type { AgentHarness } from "./agent-harness.ts";
+102 -42
View File
@@ -1,16 +1,24 @@
import { type ExecutionEnv, ExecutionError, err, ok, type Result, type ShellExecOptions, toError } from "../types.ts";
import { DEFAULT_MAX_BYTES, truncateTail } from "./truncate.ts";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult, truncateTail } from "./truncate.ts";
export interface ShellCaptureOptions extends Omit<ShellExecOptions, "onStdout" | "onStderr"> {
onChunk?: (chunk: string) => void;
export interface ShellCaptureProgress {
output: string;
truncation: TruncationResult;
fullOutputPath?: string;
lastLineBytes: number;
}
export interface ShellCaptureResult {
output: string;
export interface ShellCaptureOptions extends Omit<ShellExecOptions, "onStdout" | "onStderr"> {
onChunk?: (chunk: string, getProgress: () => ShellCaptureProgress) => void;
/** Return shell execution failures with captured output instead of as a failed Result. */
returnExecutionErrors?: boolean;
}
export interface ShellCaptureResult extends ShellCaptureProgress {
exitCode: number | undefined;
cancelled: boolean;
truncated: boolean;
fullOutputPath?: string;
executionError?: ExecutionError;
}
function toExecutionError(error: unknown): ExecutionError {
@@ -32,63 +40,104 @@ export function sanitizeBinaryOutput(str: string): string {
.join("");
}
function trimToLastUtf8Bytes(text: string, maxBytes: number, encoder: { encode(input?: string): Uint8Array }): string {
const bytes = encoder.encode(text);
if (bytes.byteLength <= maxBytes) return text;
let start = bytes.byteLength - maxBytes;
while (start < bytes.byteLength && ((bytes[start] ?? 0) & 0xc0) === 0x80) start++;
return new TextDecoder().decode(bytes.subarray(start));
}
export async function executeShellWithCapture(
env: ExecutionEnv,
command: string,
options?: ShellCaptureOptions,
): Promise<Result<ShellCaptureResult, ExecutionError>> {
const outputChunks: string[] = [];
let outputBytes = 0;
let tailOutput = "";
const maxOutputBytes = DEFAULT_MAX_BYTES * 2;
const encoder = new TextEncoder();
let totalBytes = 0;
let completedLines = 0;
let hasOpenLine = false;
let currentLineBytes = 0;
let fullOutputPath: string | undefined;
let fullOutputRequested = false;
let acceptingOutput = true;
let writeChain: Promise<Result<void, ExecutionError>> = Promise.resolve(ok(undefined));
let captureError: ExecutionError | undefined;
const appendFullOutput = (text: string): void => {
if (!fullOutputPath || captureError) return;
const path = fullOutputPath;
if (!fullOutputRequested || captureError) return;
writeChain = writeChain.then(async (previous) => {
if (!previous.ok) return previous;
const appendResult = await env.appendFile(path, text, options?.abortSignal);
if (!fullOutputPath) return err(new ExecutionError("unknown", "Full output path was not created"));
const appendResult = await env.appendFile(fullOutputPath, text);
return appendResult.ok ? ok(undefined) : err(toExecutionError(appendResult.error));
});
};
const ensureFullOutputFile = (initialContent: string): void => {
if (fullOutputPath || captureError) return;
if (fullOutputRequested || captureError) return;
fullOutputRequested = true;
writeChain = writeChain.then(async (previous) => {
if (!previous.ok) return previous;
const tempFile = await env.createTempFile({
prefix: "bash-",
suffix: ".log",
abortSignal: options?.abortSignal,
});
const tempFile = await env.createTempFile({ prefix: "bash-", suffix: ".log" });
if (!tempFile.ok) return err(toExecutionError(tempFile.error));
fullOutputPath = tempFile.value;
const appendResult = await env.appendFile(tempFile.value, initialContent, options?.abortSignal);
const appendResult = await env.appendFile(tempFile.value, initialContent);
return appendResult.ok ? ok(undefined) : err(toExecutionError(appendResult.error));
});
};
const onChunk = (chunk: string) => {
const createProgress = (): ShellCaptureProgress => {
const tailTruncation = truncateTail(tailOutput);
const totalLines = completedLines + (hasOpenLine ? 1 : 0);
const truncated = totalLines > DEFAULT_MAX_LINES || totalBytes > DEFAULT_MAX_BYTES;
const truncation: TruncationResult = {
...tailTruncation,
truncated,
truncatedBy: truncated
? (tailTruncation.truncatedBy ?? (totalBytes > DEFAULT_MAX_BYTES ? "bytes" : "lines"))
: null,
totalLines,
totalBytes,
};
return {
output: truncated ? truncation.content : tailOutput,
truncation,
fullOutputPath,
lastLineBytes: currentLineBytes,
};
};
const onChunk = (chunk: string): void => {
if (!acceptingOutput) return;
try {
totalBytes += encoder.encode(chunk).byteLength;
const text = sanitizeBinaryOutput(chunk).replace(/\r/g, "");
if (totalBytes > DEFAULT_MAX_BYTES && !fullOutputPath) {
ensureFullOutputFile(outputChunks.join("") + text);
} else {
const textBytes = encoder.encode(text).byteLength;
totalBytes += textBytes;
const newlineCount = text.split("\n").length - 1;
completedLines += newlineCount;
const lastNewline = text.lastIndexOf("\n");
if (lastNewline >= 0) {
const trailingText = text.slice(lastNewline + 1);
currentLineBytes = encoder.encode(trailingText).byteLength;
hasOpenLine = trailingText.length > 0;
} else if (text.length > 0) {
currentLineBytes += textBytes;
hasOpenLine = true;
}
tailOutput += text;
const totalLines = completedLines + (hasOpenLine ? 1 : 0);
if ((totalBytes > DEFAULT_MAX_BYTES || totalLines > DEFAULT_MAX_LINES) && !fullOutputRequested) {
ensureFullOutputFile(tailOutput);
} else if (fullOutputRequested) {
appendFullOutput(text);
}
outputChunks.push(text);
outputBytes += text.length;
while (outputBytes > maxOutputBytes && outputChunks.length > 1) {
const removed = outputChunks.shift()!;
outputBytes -= removed.length;
}
options?.onChunk?.(text);
tailOutput = trimToLastUtf8Bytes(tailOutput, maxOutputBytes, encoder);
options?.onChunk?.(text, createProgress);
} catch (error) {
captureError = toExecutionError(error);
}
@@ -96,40 +145,51 @@ export async function executeShellWithCapture(
try {
const result = await env.exec(command, {
...(options ?? {}),
cwd: options?.cwd,
env: options?.env,
inheritEnv: options?.inheritEnv,
timeout: options?.timeout,
abortSignal: options?.abortSignal,
onStdout: onChunk,
onStderr: onChunk,
});
const tailOutput = outputChunks.join("");
const truncationResult = truncateTail(tailOutput);
if (truncationResult.truncated && !fullOutputPath) {
ensureFullOutputFile(tailOutput);
}
acceptingOutput = false;
let progress = createProgress();
if (progress.truncation.truncated && !fullOutputRequested) ensureFullOutputFile(tailOutput);
const writeResult = await writeChain;
if (!writeResult.ok) return err(writeResult.error);
if (captureError) return err(captureError);
progress = createProgress();
if (!result.ok) {
if (result.error.code === "aborted" || options?.abortSignal?.aborted) {
return ok({
output: truncationResult.truncated ? truncationResult.content : tailOutput,
...progress,
exitCode: undefined,
cancelled: true,
truncated: truncationResult.truncated,
fullOutputPath,
truncated: progress.truncation.truncated,
});
}
if (options?.returnExecutionErrors) {
return ok({
...progress,
exitCode: undefined,
cancelled: false,
truncated: progress.truncation.truncated,
executionError: result.error,
});
}
return err(result.error);
}
const cancelled = options?.abortSignal?.aborted ?? false;
return ok({
output: truncationResult.truncated ? truncationResult.content : tailOutput,
...progress,
exitCode: cancelled ? undefined : result.value.exitCode,
cancelled,
truncated: truncationResult.truncated,
fullOutputPath,
truncated: progress.truncation.truncated,
});
} catch (error) {
acceptingOutput = false;
return err(toExecutionError(error));
}
}
+9 -3
View File
@@ -79,6 +79,13 @@ function utf8ByteLength(content: string): number {
return bytes;
}
function splitLinesForCounting(content: string): string[] {
if (content.length === 0) return [];
const lines = content.split("\n");
if (content.endsWith("\n")) lines.pop();
return lines;
}
function replaceUnpairedSurrogates(content: string): string {
let output = "";
for (let i = 0; i < content.length; i++) {
@@ -127,7 +134,7 @@ export function truncateHead(content: string, options: TruncationOptions = {}):
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
const totalBytes = utf8ByteLength(content);
const lines = content.split("\n");
const lines = splitLinesForCounting(content);
const totalLines = lines.length;
// Check if no truncation needed
@@ -217,8 +224,7 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
const totalBytes = utf8ByteLength(content);
const lines = content.split("\n");
if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
const lines = splitLinesForCounting(content);
const totalLines = lines.length;
// Check if no truncation needed
+1
View File
@@ -37,6 +37,7 @@ export * from "./harness/session/repo-utils.ts";
export * from "./harness/session/session.ts";
export * from "./harness/skills.ts";
export * from "./harness/system-prompt.ts";
export * from "./harness/tools/index.ts";
// Harness
export * from "./harness/types.ts";
export * from "./harness/utils/shell-output.ts";
@@ -8,9 +8,9 @@ import {
} from "@earendil-works/pi-ai";
import { describe, expect, it } from "vitest";
import { AgentHarness } from "../../src/harness/agent-harness.ts";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
import { Session } from "../../src/harness/session/session.ts";
import type { AgentHarnessOptions } from "../../src/harness/types.ts";
import { calculateTool } from "../utils/calculate.ts";
/** Shared collection; each faux provider gets a unique id so coexisting fakes route correctly. */
@@ -23,7 +23,7 @@ function newFaux(): FauxProviderHandle {
return faux;
}
function createHarness(options: ConstructorParameters<typeof AgentHarness>[0]): AgentHarness {
function createHarness(options: AgentHarnessOptions): AgentHarness {
return new AgentHarness(options);
}
@@ -49,7 +49,6 @@ describe("AgentHarness stream configuration", () => {
const session = new Session(new InMemorySessionStorage({ metadata: { id: "session-1", createdAt: "now" } }));
const harness = createHarness({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session,
model: registration.getModel(),
streamOptions: {
@@ -98,7 +97,6 @@ describe("AgentHarness stream configuration", () => {
const harness = createHarness({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
streamOptions: {
@@ -156,7 +154,6 @@ describe("AgentHarness stream configuration", () => {
const harness = createHarness({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
tools: [calculateTool],
@@ -191,7 +188,6 @@ describe("AgentHarness stream configuration", () => {
const harness = createHarness({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
});
@@ -13,7 +13,7 @@ import { AgentHarness } from "../../src/harness/agent-harness.ts";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
import { Session } from "../../src/harness/session/session.ts";
import type { PromptTemplate, Skill } from "../../src/harness/types.ts";
import type { AgentHarnessTool, PromptTemplate, Skill } from "../../src/harness/types.ts";
import type { AgentMessage, AgentTool } from "../../src/types.ts";
import { calculateTool, createCalculateToolWithUsage } from "../utils/calculate.ts";
import { getCurrentTimeTool } from "../utils/get-current-time.ts";
@@ -92,11 +92,9 @@ function createAssistantMessage(text: string): AgentMessage {
describe("AgentHarness", () => {
it("constructs directly and exposes queue modes", () => {
const session = new Session(new InMemorySessionStorage());
const env = new NodeExecutionEnv({ cwd: process.cwd() });
const initialModel = getModel("anthropic", "claude-sonnet-4-5");
const harness = new AgentHarness({
models,
env,
session,
model: initialModel,
thinkingLevel: "high",
@@ -104,7 +102,6 @@ describe("AgentHarness", () => {
steeringMode: "all",
followUpMode: "all",
});
expect(harness.env).toBe(env);
expect(harness.getModel()).toBe(initialModel);
expect(harness.getThinkingLevel()).toBe("high");
expect(harness.getSteeringMode()).toBe("all");
@@ -134,7 +131,6 @@ describe("AgentHarness", () => {
]);
const harness = new AgentHarness({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
steeringMode: "one-at-a-time",
@@ -170,7 +166,6 @@ describe("AgentHarness", () => {
const session = new Session(new InMemorySessionStorage());
const harness = new AgentHarness({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session,
model: registration.getModel(),
});
@@ -211,7 +206,6 @@ describe("AgentHarness", () => {
]);
const harness = new AgentHarness({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
});
@@ -264,7 +258,6 @@ describe("AgentHarness", () => {
]);
const harness = new AgentHarness({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
followUpMode: "one-at-a-time",
@@ -294,7 +287,6 @@ describe("AgentHarness", () => {
const session = new Session(new InMemorySessionStorage());
const harness = new AgentHarness({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session,
model: registration.getModel(),
});
@@ -351,9 +343,8 @@ describe("AgentHarness", () => {
return fauxAssistantMessage("done");
},
]);
const harness = new AgentHarness<Skill, PromptTemplate, AgentTool>({
const harness = new AgentHarness<undefined, Skill, PromptTemplate, AgentTool>({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
thinkingLevel: "off",
@@ -390,7 +381,6 @@ describe("AgentHarness", () => {
const session = new Session(new InMemorySessionStorage());
const harness = new AgentHarness({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session,
model: registration.getModel(),
});
@@ -421,7 +411,6 @@ describe("AgentHarness", () => {
const barrier = deferred();
const harness = new AgentHarness({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
});
@@ -461,7 +450,6 @@ describe("AgentHarness", () => {
const calculateToolWithUsage = createCalculateToolWithUsage(toolUsage);
const harness = new AgentHarness({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session,
model: registration.getModel(),
tools: [calculateToolWithUsage],
@@ -502,6 +490,75 @@ describe("AgentHarness", () => {
});
});
it("passes a static application context to harness tools", async () => {
const registration = newFaux();
registration.setResponses([
() =>
fauxAssistantMessage(fauxToolCall("context", { expression: "2 + 2" }, { id: "call-1" }), {
stopReason: "toolUse",
}),
]);
const env = new NodeExecutionEnv({ cwd: process.cwd() });
const toolContext = { env };
let receivedContext: typeof toolContext | undefined;
const contextTool: AgentHarnessTool<typeof toolContext, typeof calculateTool.parameters, undefined> = {
...calculateTool,
name: "context",
execute: async (toolCallId, params, signal, onUpdate, context) => {
receivedContext = context;
return { ...(await calculateTool.execute(toolCallId, params, signal, onUpdate)), terminate: true };
},
};
const harness = new AgentHarness({
models,
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
tools: [contextTool],
toolContext,
});
await harness.prompt("hello");
expect(receivedContext).toBe(toolContext);
});
it("resolves async tool context providers for each turn snapshot", async () => {
const registration = newFaux();
registration.setResponses([
() =>
fauxAssistantMessage(fauxToolCall("context", { expression: "1 + 1" }, { id: "call-1" }), {
stopReason: "toolUse",
}),
() =>
fauxAssistantMessage(fauxToolCall("context", { expression: "2 + 2" }, { id: "call-2" }), {
stopReason: "toolUse",
}),
() => fauxAssistantMessage("done"),
]);
type ToolContext = { generation: number };
const generations: number[] = [];
const contextTool: AgentHarnessTool<ToolContext, typeof calculateTool.parameters, undefined> = {
...calculateTool,
name: "context",
execute: async (toolCallId, params, signal, onUpdate, context) => {
generations.push(context.generation);
return await calculateTool.execute(toolCallId, params, signal, onUpdate);
},
};
let generation = 0;
const harness = new AgentHarness({
models,
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
tools: [contextTool],
toolContext: async (): Promise<ToolContext> => ({ generation: ++generation }),
});
await harness.prompt("hello");
expect(generations).toEqual([1, 2]);
});
it("persists generated compaction usage", async () => {
const registration = newFaux();
registration.setResponses([fauxAssistantMessage("## Goal\nTest summary")]);
@@ -510,7 +567,6 @@ describe("AgentHarness", () => {
await session.appendMessage(createAssistantMessage("two"));
const harness = new AgentHarness({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session,
model: registration.getModel(),
});
@@ -530,7 +586,6 @@ describe("AgentHarness", () => {
await session.appendMessage(createAssistantMessage("two"));
const harness = new AgentHarness({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session,
model: registration.getModel(),
});
@@ -569,7 +624,6 @@ describe("AgentHarness", () => {
await session.appendMessage(createAssistantMessage("two"));
const harness = new AgentHarness({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session,
model: registration.getModel(),
retry: { enabled: true, maxRetries: 1, baseDelayMs: 0 },
@@ -610,7 +664,6 @@ describe("AgentHarness", () => {
await session.appendMessage(createAssistantMessage("two"));
const harness = new AgentHarness({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session,
model: registration.getModel(),
retry: { enabled: true, maxRetries: 1, baseDelayMs: 0 },
@@ -646,7 +699,6 @@ describe("AgentHarness", () => {
await session.appendMessage(createAssistantMessage("two"));
const harness = new AgentHarness({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session,
model: registration.getModel(),
retry: { enabled: true, maxRetries: 3, baseDelayMs: 0 },
@@ -696,7 +748,6 @@ describe("AgentHarness", () => {
await session.appendMessage(createAssistantMessage("abandoned reply"));
const harness = new AgentHarness({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session,
model: registration.getModel(),
retry: { enabled: true, maxRetries: 1, baseDelayMs: 0 },
@@ -734,7 +785,6 @@ describe("AgentHarness", () => {
await session.appendMessage(createAssistantMessage("abandoned reply"));
const harness = new AgentHarness({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session,
model: registration.getModel(),
});
@@ -754,7 +804,6 @@ describe("AgentHarness", () => {
await session.appendMessage(createAssistantMessage("abandoned reply"));
const harness = new AgentHarness({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session,
model: registration.getModel(),
});
@@ -769,14 +818,12 @@ describe("AgentHarness", () => {
it("preserves app tool types for getters and update events", async () => {
const session = new Session(new InMemorySessionStorage());
const env = new NodeExecutionEnv({ cwd: process.cwd() });
const model = getModel("anthropic", "claude-sonnet-4-5");
type AppTool = AgentTool<typeof calculateTool.parameters, undefined> & { source: "builtin" | "extension" };
const inspectTool: AppTool = { ...calculateTool, name: "inspect", source: "builtin" };
const searchTool: AppTool = { ...calculateTool, name: "search", source: "extension" };
const harness = new AgentHarness<AppSkill, AppPromptTemplate, AppTool>({
const harness = new AgentHarness<undefined, AppSkill, AppPromptTemplate, AppTool>({
models,
env,
session,
model,
tools: [inspectTool, searchTool],
@@ -841,16 +888,14 @@ describe("AgentHarness", () => {
it("validates constructor tool names", () => {
const session = new Session(new InMemorySessionStorage());
const env = new NodeExecutionEnv({ cwd: process.cwd() });
const model = getModel("anthropic", "claude-sonnet-4-5");
expect(
() => new AgentHarness({ env, session, models, model, tools: [calculateTool], activeToolNames: ["missing"] }),
() => new AgentHarness({ session, models, model, tools: [calculateTool], activeToolNames: ["missing"] }),
).toThrow(/Unknown tool/);
expect(
() =>
new AgentHarness({
models,
env,
session,
model,
tools: [calculateTool, calculateTool],
@@ -861,7 +906,6 @@ describe("AgentHarness", () => {
() =>
new AgentHarness({
models,
env,
session,
model,
tools: [calculateTool],
@@ -872,9 +916,12 @@ describe("AgentHarness", () => {
it("preserves app resource types for getters and update events", async () => {
const session = new Session(new InMemorySessionStorage());
const env = new NodeExecutionEnv({ cwd: process.cwd() });
const model = getModel("anthropic", "claude-sonnet-4-5");
const harness = new AgentHarness<AppSkill, AppPromptTemplate, AgentTool>({ env, session, models, model });
const harness = new AgentHarness<undefined, AppSkill, AppPromptTemplate, AgentTool>({
session,
models,
model,
});
const skill: AppSkill = {
name: "inspect",
description: "Inspect things",
@@ -1,5 +1,9 @@
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { access, chmod, realpath, symlink } from "node:fs/promises";
import { homedir } from "node:os";
import { delimiter, join } from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { FileError, getOrThrow } from "../../src/harness/types.ts";
@@ -8,6 +12,52 @@ import { createTempDir } from "./session-test-utils.ts";
const chmodRestorePaths: string[] = [];
function withTimeout<T>(promise: Promise<T>, ms: number, onTimeout?: () => void): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => {
onTimeout?.();
reject(new Error(`Timed out after ${ms}ms`));
}, ms);
promise.then(
(value) => {
clearTimeout(timeoutId);
resolve(value);
},
(error: unknown) => {
clearTimeout(timeoutId);
reject(error);
},
);
});
}
function toBashSingleQuotedArg(value: string): string {
return `'${value.replace(/\\/g, "/").replace(/'/g, `'"'"'`)}'`;
}
function createInheritedStdioCommand(pidFile: string): string {
return (
'node -e "' +
"const fs=require('fs');" +
"const {spawn}=require('child_process');" +
"const child=spawn(process.execPath,['-e','setTimeout(()=>{},60000)'],{stdio:'inherit',detached:true});" +
"fs.writeFileSync(process.argv[1], String(child.pid));" +
"child.unref();" +
"console.log('child-exiting');" +
'" ' +
toBashSingleQuotedArg(pidFile)
);
}
function cleanupDetachedChild(pidFile: string): void {
if (!existsSync(pidFile)) return;
const pid = Number.parseInt(readFileSync(pidFile, "utf8").trim(), 10);
if (!Number.isFinite(pid) || pid <= 0) return;
try {
execFileSync("taskkill", ["/F", "/T", "/PID", String(pid)], { stdio: "ignore" });
} catch {}
}
afterEach(async () => {
for (const path of chmodRestorePaths.splice(0)) {
try {
@@ -45,6 +95,14 @@ describe("NodeExecutionEnv", () => {
expect(getOrThrow(await env.exists("nested/child/file.txt"))).toBe(false);
});
it("expands home-relative paths and file URLs", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
expect(getOrThrow(await env.absolutePath("~/pi-node-env-test"))).toBe(join(homedir(), "pi-node-env-test"));
const filePath = join(root, "file with spaces.txt");
expect(getOrThrow(await env.absolutePath(pathToFileURL(filePath).href))).toBe(filePath);
});
it("returns fileInfo for files, directories, and symlinks without following symlinks", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
@@ -201,6 +259,29 @@ describe("NodeExecutionEnv", () => {
expect(result).toEqual({ stdout: `${await realpath(root)}:ok`, stderr: "", exitCode: 0 });
});
it("can replace rather than inherit the default shell environment", async () => {
const root = createTempDir();
const inheritedKey = "PI_NODE_ENV_INHERITED_TEST";
const configuredKey = "PI_NODE_ENV_CONFIGURED_TEST";
const explicitKey = "PI_NODE_ENV_EXPLICIT_TEST";
const previousInherited = process.env[inheritedKey];
process.env[inheritedKey] = "host";
try {
const env = new NodeExecutionEnv({ cwd: root, shellEnv: { [configuredKey]: "configured" } });
const result = getOrThrow(
await env.exec(`printf '%s:%s:%s' "\${${inheritedKey}-}" "\${${configuredKey}-}" "\${${explicitKey}-}"`, {
inheritEnv: false,
env: { [explicitKey]: "explicit" },
}),
);
expect(result.stdout).toBe("::explicit");
} finally {
if (previousInherited === undefined) delete process.env[inheritedKey];
else process.env[inheritedKey] = previousInherited;
}
});
it("uses stdin command transport for legacy WSL bash paths", async () => {
if (process.platform === "win32") return;
const root = createTempDir();
@@ -234,6 +315,41 @@ describe("NodeExecutionEnv", () => {
}
});
it.skipIf(process.platform !== "win32")(
"settles after the shell exits when a detached descendant retains inherited stdio",
async () => {
const root = createTempDir();
const pidFile = join(root, "grandchild.pid");
const env = new NodeExecutionEnv({ cwd: root });
const controller = new AbortController();
try {
const result = getOrThrow(
await withTimeout(
env.exec(createInheritedStdioCommand(pidFile), { abortSignal: controller.signal }),
3000,
() => controller.abort(),
),
);
expect(result.stdout).toContain("child-exiting");
} finally {
controller.abort();
cleanupDetachedChild(pidFile);
}
},
);
it("cleanup terminates active shell processes", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const execution = env.exec("touch started; sleep 60");
for (let attempt = 0; attempt < 100 && !getOrThrow(await env.exists("started")); attempt++) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
expect(getOrThrow(await env.exists("started"))).toBe(true);
await env.cleanup();
await expect(withTimeout(execution, 3000)).resolves.toMatchObject({ ok: true });
});
it("streams stdout and stderr chunks", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
@@ -254,6 +370,17 @@ describe("NodeExecutionEnv", () => {
expect(stderr).toBe("err");
});
it("reports a missing working directory before spawning", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: join(root, "missing") });
const result = await env.exec("printf ok");
expect(result).toMatchObject({
ok: false,
error: { code: "spawn_error", message: expect.stringContaining("Working directory does not exist") },
});
});
it("returns non-zero command exit codes as successful execution results", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
@@ -0,0 +1,17 @@
import type { Api, Model, Models } from "@earendil-works/pi-ai";
import { AgentHarness } from "../../src/harness/agent-harness.ts";
import { createReadTool } from "../../src/harness/tools/read.ts";
import type { ExecutionToolContext } from "../../src/harness/tools/tool-context.ts";
import type { Session } from "../../src/harness/types.ts";
declare const models: Models;
declare const model: Model<Api>;
declare const session: Session;
declare const toolContext: ExecutionToolContext;
const readTool = createReadTool();
new AgentHarness({ models, model, session, tools: [readTool], toolContext });
// @ts-expect-error Context-requiring tools must be paired with toolContext.
new AgentHarness({ models, model, session, tools: [readTool] });
+610
View File
@@ -0,0 +1,610 @@
import { symlink } from "node:fs/promises";
import { applyPatch } from "diff";
import { describe, expect, it } from "vitest";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { type BashToolDetails, createBashTool } from "../../src/harness/tools/bash.ts";
import { createEditTool } from "../../src/harness/tools/edit.ts";
import { createReadTool } from "../../src/harness/tools/read.ts";
import { createWriteTool } from "../../src/harness/tools/write.ts";
import {
type ExecutionError,
type FileError,
getOrThrow,
ok,
type Result,
type ShellExecOptions,
} from "../../src/harness/types.ts";
import { createTempDir } from "./session-test-utils.ts";
function textOutput(result: { content: Array<{ type: string; text?: string }> }): string {
return result.content.flatMap((part) => (part.type === "text" ? [part.text ?? ""] : [])).join("\n");
}
function createContext() {
const env = new NodeExecutionEnv({ cwd: createTempDir() });
return { env };
}
function deferred(): { promise: Promise<void>; resolve: () => void } {
let resolve = () => {};
const promise = new Promise<void>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
class SlowReadExecutionEnv extends NodeExecutionEnv {
override async readTextFile(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>> {
await delay(20);
return super.readTextFile(path, abortSignal);
}
}
class BlockingWriteExecutionEnv extends NodeExecutionEnv {
readonly firstWriteStarted = deferred();
readonly finishFirstWrite = deferred();
secondWriteStarted = false;
override async writeFile(
path: string,
content: string | Uint8Array,
abortSignal?: AbortSignal,
): Promise<Result<void, FileError>> {
if (content === "first\n") {
this.firstWriteStarted.resolve();
await this.finishFirstWrite.promise;
} else if (content === "second\n") {
this.secondWriteStarted = true;
}
return super.writeFile(path, content, abortSignal);
}
}
class BlockingEditExecutionEnv extends NodeExecutionEnv {
readonly firstEditWriteStarted = deferred();
readonly finishFirstEditWrite = deferred();
firstEditWriteSettled = false;
secondEditWriteStarted = false;
override async writeFile(
path: string,
content: string | Uint8Array,
abortSignal?: AbortSignal,
): Promise<Result<void, FileError>> {
if (content === "ALPHA\nbeta\n") {
this.firstEditWriteStarted.resolve();
await this.finishFirstEditWrite.promise;
const result = await super.writeFile(path, content);
this.firstEditWriteSettled = true;
return result;
}
if (content === "ALPHA\nBETA\n" || content === "alpha\nBETA\n") {
this.secondEditWriteStarted = true;
}
return super.writeFile(path, content, abortSignal);
}
}
class LateOutputExecutionEnv extends NodeExecutionEnv {
override async exec(
_command: string,
options?: ShellExecOptions,
): Promise<Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>> {
options?.onStdout?.("before\n");
setTimeout(() => options?.onStdout?.("late\n"), 0);
return ok({ stdout: "before\n", stderr: "", exitCode: 0 });
}
}
function createTinyBmp(): Uint8Array {
const bytes = new Uint8Array(58);
const view = new DataView(bytes.buffer);
bytes[0] = 0x42;
bytes[1] = 0x4d;
view.setUint32(2, bytes.length, true);
view.setUint32(10, 54, true);
view.setUint32(14, 40, true);
view.setInt32(18, 1, true);
view.setInt32(22, 1, true);
view.setUint16(26, 1, true);
view.setUint16(28, 24, true);
view.setUint32(34, 4, true);
return bytes;
}
describe("AgentHarness tools", () => {
describe("read", () => {
it("reads text with offsets, limits, and continuation notices", async () => {
const context = createContext();
getOrThrow(
await context.env.writeFile(
"test.txt",
Array.from({ length: 100 }, (_, index) => `Line ${index + 1}`).join("\n"),
),
);
const result = await createReadTool().execute(
"read-1",
{ path: "test.txt", offset: 41, limit: 20 },
undefined,
undefined,
context,
);
const output = textOutput(result);
expect(output).not.toContain("Line 40");
expect(output).toContain("Line 41");
expect(output).toContain("Line 60");
expect(output).not.toContain("Line 61");
expect(output).toContain("[40 more lines in file. Use offset=61 to continue.]");
});
it("truncates large text by line count", async () => {
const context = createContext();
getOrThrow(
await context.env.writeFile(
"large.txt",
Array.from({ length: 2500 }, (_, index) => `Line ${index + 1}`).join("\n"),
),
);
const result = await createReadTool().execute("read-2", { path: "large.txt" }, undefined, undefined, context);
expect(textOutput(result)).toContain("[Showing lines 1-2000 of 2500. Use offset=2001 to continue.]");
expect(result.details?.truncation).toMatchObject({
truncated: true,
truncatedBy: "lines",
totalLines: 2500,
outputLines: 2000,
});
});
it("does not count a trailing newline as an extra line at the truncation limit", async () => {
const context = createContext();
getOrThrow(
await context.env.writeFile("exact.txt", `${Array.from({ length: 2000 }, () => "x").join("\n")}\n`),
);
const result = await createReadTool().execute(
"read-exact",
{ path: "exact.txt" },
undefined,
undefined,
context,
);
expect(result.details).toBeUndefined();
expect(textOutput(result)).not.toContain("Use offset=");
});
it("rejects offsets beyond the file", async () => {
const context = createContext();
getOrThrow(await context.env.writeFile("short.txt", "one\ntwo\nthree"));
await expect(
createReadTool().execute("read-3", { path: "short.txt", offset: 100 }, undefined, undefined, context),
).rejects.toThrow("Offset 100 is beyond end of file (3 lines total)");
});
it("detects supported images by content", async () => {
const context = createContext();
const png = Uint8Array.from(
Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAX+XDSwAAAABJRU5ErkJggg==",
"base64",
),
);
getOrThrow(await context.env.writeFile("image.txt", png));
const result = await createReadTool().execute("read-4", { path: "image.txt" }, undefined, undefined, context);
expect(textOutput(result)).toContain("Read image file [image/png]");
expect(result.content).toContainEqual({
type: "image",
data: Buffer.from(png).toString("base64"),
mimeType: "image/png",
});
});
it("delegates image conversion and resizing to an injected processor", async () => {
const context = createContext();
const bmp = createTinyBmp();
getOrThrow(await context.env.writeFile("image.bmp", bmp));
let received: { bytes: Uint8Array; mimeType: string; autoResizeImages: boolean } | undefined;
const tool = createReadTool({
autoResizeImages: false,
imageProcessor: async (bytes, mimeType, options) => {
received = { bytes, mimeType, autoResizeImages: options.autoResizeImages };
return {
ok: true,
data: "converted",
mimeType: "image/png",
hints: ["[Image converted from image/bmp to image/png.]"],
};
},
});
const result = await tool.execute("read-bmp", { path: "image.bmp" }, undefined, undefined, context);
expect(received).toMatchObject({ mimeType: "image/bmp", autoResizeImages: false });
expect(Array.from(received?.bytes ?? [])).toEqual(Array.from(bmp));
expect(textOutput(result)).toContain("[Image converted from image/bmp to image/png.]");
expect(result.content).toContainEqual({ type: "image", data: "converted", mimeType: "image/png" });
});
});
describe("write", () => {
it("writes files and creates parent directories", async () => {
const context = createContext();
const result = await createWriteTool().execute(
"write-1",
{ path: "nested/dir/file.txt", content: "hello" },
undefined,
undefined,
context,
);
expect(textOutput(result)).toBe("Successfully wrote 5 bytes to nested/dir/file.txt");
expect(getOrThrow(await context.env.readTextFile("nested/dir/file.txt"))).toBe("hello");
});
it("keeps the mutation queue locked until an aborted write settles", async () => {
const env = new BlockingWriteExecutionEnv({ cwd: createTempDir() });
const tool = createWriteTool();
const controller = new AbortController();
const firstWrite = tool.execute(
"write-first",
{ path: "file.txt", content: "first\n" },
controller.signal,
undefined,
{
env,
},
);
await env.firstWriteStarted.promise;
controller.abort();
const secondWrite = tool.execute(
"write-second",
{ path: "file.txt", content: "second\n" },
undefined,
undefined,
{ env },
);
await delay(20);
expect(env.secondWriteStarted).toBe(false);
env.finishFirstWrite.resolve();
await expect(firstWrite).rejects.toThrow();
await secondWrite;
expect(getOrThrow(await env.readTextFile("file.txt"))).toBe("second\n");
});
});
describe("edit", () => {
it("applies disjoint edits and returns both diff formats", async () => {
const context = createContext();
const original = "alpha\nbeta\ngamma\ndelta\n";
getOrThrow(await context.env.writeFile("edit.txt", original));
const result = await createEditTool().execute(
"edit-1",
{
path: "edit.txt",
edits: [
{ oldText: "alpha\n", newText: "ALPHA\n" },
{ oldText: "gamma\n", newText: "GAMMA\n" },
],
},
undefined,
undefined,
context,
);
expect(textOutput(result)).toBe("Successfully replaced 2 block(s) in edit.txt.");
expect(result.details?.diff).toContain("ALPHA");
expect(result.details?.diff).toContain("GAMMA");
expect(applyPatch(original, result.details?.patch ?? "")).toBe("ALPHA\nbeta\nGAMMA\ndelta\n");
expect(getOrThrow(await context.env.readTextFile("edit.txt"))).toBe("ALPHA\nbeta\nGAMMA\ndelta\n");
});
it("matches all edits against the original and rejects overlaps", async () => {
const context = createContext();
getOrThrow(await context.env.writeFile("edit.txt", "one\ntwo\nthree\n"));
await expect(
createEditTool().execute(
"edit-2",
{
path: "edit.txt",
edits: [
{ oldText: "one\ntwo\n", newText: "ONE\nTWO\n" },
{ oldText: "two\nthree\n", newText: "TWO\nTHREE\n" },
],
},
undefined,
undefined,
context,
),
).rejects.toThrow(/overlap/);
expect(getOrThrow(await context.env.readTextFile("edit.txt"))).toBe("one\ntwo\nthree\n");
});
it("rejects missing and duplicate target text", async () => {
const context = createContext();
getOrThrow(await context.env.writeFile("edit.txt", "foo foo foo"));
const tool = createEditTool();
await expect(
tool.execute(
"edit-3",
{ path: "edit.txt", edits: [{ oldText: "bar", newText: "baz" }] },
undefined,
undefined,
context,
),
).rejects.toThrow(/Could not find the exact text/);
await expect(
tool.execute(
"edit-4",
{ path: "edit.txt", edits: [{ oldText: "foo", newText: "bar" }] },
undefined,
undefined,
context,
),
).rejects.toThrow(/Found 3 occurrences/);
});
it("keeps the mutation queue locked until an aborted edit write settles", async () => {
const env = new BlockingEditExecutionEnv({ cwd: createTempDir() });
getOrThrow(await env.writeFile("file.txt", "alpha\nbeta\n"));
const tool = createEditTool();
const controller = new AbortController();
const firstEdit = tool.execute(
"edit-first",
{ path: "file.txt", edits: [{ oldText: "alpha", newText: "ALPHA" }] },
controller.signal,
undefined,
{ env },
);
await env.firstEditWriteStarted.promise;
controller.abort();
const secondEdit = tool.execute(
"edit-second",
{ path: "file.txt", edits: [{ oldText: "beta", newText: "BETA" }] },
undefined,
undefined,
{ env },
);
await delay(20);
expect(env.secondEditWriteStarted).toBe(false);
env.finishFirstEditWrite.resolve();
await expect(firstEdit).rejects.toThrow("Operation aborted");
await secondEdit;
expect(env.firstEditWriteSettled).toBe(true);
expect(getOrThrow(await env.readTextFile("file.txt"))).toBe("ALPHA\nBETA\n");
});
it("serializes concurrent edits through canonical and symlink paths", async () => {
const env = new SlowReadExecutionEnv({ cwd: createTempDir() });
getOrThrow(await env.writeFile("target.txt", "alpha\nbeta\ngamma\n"));
await symlink("target.txt", `${env.cwd}/link.txt`);
const tool = createEditTool();
await Promise.all([
tool.execute(
"edit-target",
{ path: "target.txt", edits: [{ oldText: "alpha", newText: "ALPHA" }] },
undefined,
undefined,
{ env },
),
tool.execute(
"edit-link",
{ path: "link.txt", edits: [{ oldText: "beta", newText: "BETA" }] },
undefined,
undefined,
{ env },
),
]);
expect(getOrThrow(await env.readTextFile("target.txt"))).toBe("ALPHA\nBETA\ngamma\n");
});
it("edits regular files through symlinks", async () => {
const context = createContext();
getOrThrow(await context.env.writeFile("target.txt", "before\n"));
await symlink("target.txt", `${context.env.cwd}/link.txt`);
await createEditTool().execute(
"edit-symlink",
{ path: "link.txt", edits: [{ oldText: "before", newText: "after" }] },
undefined,
undefined,
context,
);
expect(getOrThrow(await context.env.readTextFile("target.txt"))).toBe("after\n");
});
it("preserves BOM and CRLF line endings", async () => {
const context = createContext();
getOrThrow(await context.env.writeFile("edit.txt", "\uFEFFone\r\ntwo\r\n"));
await createEditTool().execute(
"edit-5",
{ path: "edit.txt", edits: [{ oldText: "two", newText: "TWO" }] },
undefined,
undefined,
context,
);
expect(getOrThrow(await context.env.readTextFile("edit.txt"))).toBe("\uFEFFone\r\nTWO\r\n");
});
});
describe("bash", () => {
it("executes commands and combines stdout and stderr", async () => {
const context = createContext();
const result = await createBashTool().execute(
"bash-1",
{ command: "printf out; printf err >&2" },
undefined,
undefined,
context,
);
expect(textOutput(result)).toContain("out");
expect(textOutput(result)).toContain("err");
});
it("reports nonzero exits and timeouts", async () => {
const context = createContext();
const tool = createBashTool();
await expect(
tool.execute("bash-2", { command: "printf failed; exit 7" }, undefined, undefined, context),
).rejects.toThrow(/failed[\s\S]*Command exited with code 7/);
await expect(
tool.execute("bash-3", { command: "sleep 2", timeout: 0.01 }, undefined, undefined, context),
).rejects.toThrow(/Command timed out after 0.01 seconds/);
});
it("preserves truncated output when a command times out", async () => {
const context = createContext();
let error: unknown;
try {
await createBashTool().execute(
"bash-timeout-output",
{
command: "i=1; while [ $i -le 3000 ]; do echo line-$i; i=$((i + 1)); done; sleep 2",
timeout: 0.05,
},
undefined,
undefined,
context,
);
} catch (cause) {
error = cause;
}
expect(error).toBeInstanceOf(Error);
const message = (error as Error).message;
expect(message).toContain("Command timed out after 0.05 seconds");
const fullOutputPath = message.match(/Full output: ([^\]\n]+)/)?.[1];
expect(fullOutputPath).toBeDefined();
const fullOutput = getOrThrow(await context.env.readTextFile(fullOutputPath!));
expect(fullOutput).toContain("line-1\nline-2");
expect(fullOutput).toContain("line-2999\nline-3000");
});
it("ignores output callbacks after execution settles", async () => {
const env = new LateOutputExecutionEnv({ cwd: createTempDir() });
const updates: string[] = [];
const result = await createBashTool().execute(
"bash-late",
{ command: "late" },
undefined,
(update) => updates.push(textOutput(update)),
{ env },
);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(textOutput(result)).toBe("before\n");
expect(updates.some((update) => update.includes("late"))).toBe(false);
});
it("reports the total size of an oversized final line", async () => {
const context = createContext();
const result = await createBashTool().execute(
"bash-long-line",
{ command: "printf '%060000d' 0" },
undefined,
undefined,
context,
);
expect(textOutput(result)).toMatch(/Showing last 50\.0KB of line 1 \(line is 58\.6KB\)\. Full output:/);
});
it("prepares command, cwd, and an explicit environment with the turn context", async () => {
const env = new NodeExecutionEnv({
cwd: createTempDir(),
shellEnv: { PI_BASH_PREPARE_INHERITED: "inherited" },
});
getOrThrow(await env.createDir("workspace"));
const context = { env, workspace: `${env.cwd}/workspace` };
const controller = new AbortController();
let receivedContext: typeof context | undefined;
let receivedSignal: AbortSignal | undefined;
const tool = createBashTool<typeof context>({
commandPrefix: "prefix=ready",
prepare: async (execution, turnContext, signal) => {
receivedContext = turnContext;
receivedSignal = signal;
execution.cwd = turnContext.workspace;
execution.env = { PI_BASH_PREPARE_EXPLICIT: "explicit" };
execution.inheritEnv = false;
execution.command += `\nprintf '%s:%s:%s:%s' "$prefix" "\${PI_BASH_PREPARE_INHERITED-}" "$PI_BASH_PREPARE_EXPLICIT" "$PWD"`;
},
});
const result = await tool.execute("bash-prepare", { command: ":" }, controller.signal, undefined, context);
expect(receivedContext).toBe(context);
expect(receivedSignal).toBe(controller.signal);
expect(textOutput(result)).toBe(`ready::explicit:${getOrThrow(await env.canonicalPath(context.workspace))}`);
});
it("supports command prefixes", async () => {
const context = createContext();
const result = await createBashTool({ commandPrefix: "value=hello" }).execute(
"bash-4",
{ command: "printf $value" },
undefined,
undefined,
context,
);
expect(textOutput(result)).toBe("hello");
});
it("coalesces updates and persists truncated full output", async () => {
const context = createContext();
const updates: Array<{
content: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }>;
details?: BashToolDetails;
}> = [];
const result = await createBashTool().execute(
"bash-5",
{ command: "i=1; while [ $i -le 3000 ]; do echo line-$i; i=$((i + 1)); done" },
undefined,
(update) => updates.push(update),
context,
);
expect(updates.length).toBeLessThan(25);
expect(result.details?.truncation).toMatchObject({
truncated: true,
truncatedBy: "lines",
totalLines: 3000,
outputLines: 2000,
});
expect(textOutput(result)).toContain("line-3000");
expect(result.details?.fullOutputPath).toBeDefined();
const finalUpdate = updates.at(-1);
expect(finalUpdate ? textOutput(finalUpdate) : "").toContain("line-3000");
expect(finalUpdate?.details).toMatchObject({
truncation: { totalLines: 3000, totalBytes: expect.any(Number) },
fullOutputPath: result.details?.fullOutputPath,
});
const fullOutput = getOrThrow(await context.env.readTextFile(result.details!.fullOutputPath!));
expect(fullOutput).toContain("line-1\nline-2");
expect(fullOutput).toContain("line-2999\nline-3000");
});
});
});
@@ -72,6 +72,15 @@ describe("truncate utilities", () => {
expect(result.totalBytes).toBe(9);
});
it("does not count a trailing newline as an extra line", () => {
const content = `${Array.from({ length: 3 }, () => "line").join("\n")}\n`;
const head = truncateHead(content, { maxBytes: 100, maxLines: 3 });
const tail = truncateTail(content, { maxBytes: 100, maxLines: 3 });
expect(head).toMatchObject({ truncated: false, totalLines: 3, outputLines: 3 });
expect(tail).toMatchObject({ truncated: false, totalLines: 3, outputLines: 3 });
});
it("truncates head on UTF-8 byte limits without partial lines", () => {
const content = "éé\nabc";
const result = truncateHead(content, { maxBytes: 4, maxLines: 10 });
+10 -3
View File
@@ -7,6 +7,10 @@ import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
import {
AgentHarness,
createBashTool,
createEditTool,
createReadTool,
createWriteTool,
formatSkillsForSystemPrompt,
loadSourcedPromptTemplates,
loadSourcedSkills,
@@ -49,12 +53,13 @@ if (!model) {
const session = new Session(new InMemorySessionStorage());
const agent = new AgentHarness({
env,
session,
models,
model,
thinkingLevel: "low",
systemPrompt: ({ env, resources }) =>
tools: [createReadTool(), createWriteTool(), createEditTool(), createBashTool()],
toolContext: { env },
systemPrompt: ({ resources }) =>
[
"You are a helpful assistant.",
formatSkillsForSystemPrompt(resources.skills ?? []),
@@ -68,5 +73,7 @@ const agent = new AgentHarness({
},
});
const response = await agent.prompt("What skills do you have? Any duplicates?");
const response = await agent.prompt(
"What skills do you have? Any duplicates? Also use bash to get the current date and time, then read README.md and tell me what this project is about.",
);
console.log(response);
+4 -3
View File
@@ -1422,8 +1422,9 @@ Several providers support OAuth authentication instead of static API keys:
- **Anthropic** (Claude Pro/Max subscription)
- **OpenAI Codex** (ChatGPT Plus/Pro subscription, access to GPT-5.x Codex models)
- **GitHub Copilot** (Copilot subscription)
- **OpenRouter** (OAuth PKCE that mints a user-controlled API key)
Each of these providers carries an `OAuthAuth` on `provider.auth.oauth` with three operations: `login(interaction)` uses the provider-neutral `AuthInteraction.prompt()`/`notify()` protocol and returns a credential, `refresh(credential)` exchanges the refresh token, and `toAuth(credential)` derives request auth (GitHub Copilot's per-account base URL comes from here). Refresh is automatic: `models.getAuth(providerId)` and request paths refresh expired tokens under a credential-store lock, so concurrent requests and processes cannot double-refresh.
Each of these providers carries an `OAuthAuth` on `provider.auth.oauth` with three operations: `login(interaction)` uses the provider-neutral `AuthInteraction.prompt()`/`notify()` protocol and returns a credential, `refresh(credential)` refreshes expiring credentials when applicable, and `toAuth(credential)` derives request auth (GitHub Copilot's per-account base URL comes from here). Refresh is automatic: `models.getAuth(providerId)` and request paths refresh expired tokens under a credential-store lock, so concurrent requests and processes cannot double-refresh. OpenRouter's OAuth flow instead returns a permanent API key, so its refresh operation is a no-op.
```typescript
import { createModels } from '@earendil-works/pi-ai';
@@ -1533,7 +1534,7 @@ Compat is a strict superset of the root entrypoint, so a file can switch its imp
### Adding a New Provider
Adding a new LLM provider requires changes across multiple files. The layered layout: API implementations live in `src/api/`, provider factories in `src/providers/`, generated catalogs in `src/providers/<id>.models.ts`. This checklist covers all necessary steps:
Adding a new LLM provider requires changes across multiple files. The layered layout: API implementations live in `src/api/`, provider factories in `src/providers/`, stable generated catalog wrappers live in `src/providers/<id>.models.ts`, and `src/models.generated.ts` registers them. This checklist covers all necessary steps:
#### 1. Core Types (`src/types.ts`)
@@ -1555,7 +1556,7 @@ Add a lazy wrapper `src/api/<api-id>.lazy.ts` (`<name>Api()` via `lazyApi()`) so
#### 3. Model Generation (`scripts/generate-models.ts`, `scripts/generate-image-models.ts`)
- Add logic to fetch and parse models from the provider's source (e.g., models.dev API)
- Map chat/tool-capable provider model data to the standardized `Model` interface via `scripts/generate-models.ts`; regeneration emits structural `src/providers/<id>.models.ts` shards, ignored values in `src/providers/data/`, and the aggregator
- Map chat/tool-capable provider model data to the standardized `Model` interface via `scripts/generate-models.ts`; hydration groups the ignored `src/providers/data/<id>.json` values by API, while stable `src/providers/<id>.models.ts` wrappers derive exact model/API types directly from those JSON keys
- Map image-generation provider model data to the standardized `ImagesModel` interface via `scripts/generate-image-models.ts`
- Handle provider-specific quirks (pricing format, capability flags, model ID transformations)
+36 -54
View File
@@ -23,7 +23,7 @@ import {
createModelDataManifest,
type ModelDataStructure,
MODEL_DATA_MANIFEST_FILE,
readModelDataStructure,
readModelDataProviderIds,
validateGeneratedModelData,
validateModelDataDirectory,
} from "./model-data.ts";
@@ -2482,41 +2482,30 @@ async function generateModels() {
const serializeJson = (value: unknown) => `${JSON.stringify(value, null, generatorOptions.pretty ? 2 : undefined)}\n`;
const writeJson = (path: string, value: unknown) => writeFileSync(path, serializeJson(value));
let generatedDataProviderIds = sortedProviderIds;
let generatedDataProviders = jsonProviders;
let modelDataStructure: ModelDataStructure = Object.fromEntries(
sortedProviderIds.map((providerId) => [
providerId,
Object.fromEntries(
Object.entries(jsonProviders[providerId]).map(([modelId, model]) => [modelId, model.api]),
),
]),
);
const generatedDataProviderIds = generatorOptions.dataOnly
? readModelDataProviderIds(packageRoot)
: sortedProviderIds;
const missingProviderIds = generatedDataProviderIds.filter((providerId) => !jsonProviders[providerId]);
if (missingProviderIds.length > 0) {
throw new Error(`Cannot hydrate missing providers: ${missingProviderIds.join(", ")}`);
}
if (generatorOptions.dataOnly) {
modelDataStructure = readModelDataStructure(packageRoot);
generatedDataProviderIds = Object.keys(modelDataStructure);
const hydratedProviders: typeof jsonProviders = {};
const hydrationErrors: string[] = [];
for (const [providerId, expectedModels] of Object.entries(modelDataStructure)) {
hydratedProviders[providerId] = {};
for (const [modelId, expectedApi] of Object.entries(expectedModels)) {
const model = jsonProviders[providerId]?.[modelId];
if (!model) {
hydrationErrors.push(`missing ${providerId}/${modelId}`);
continue;
}
if (model.api !== expectedApi) {
hydrationErrors.push(`${providerId}/${modelId} uses ${model.api}, expected ${expectedApi}`);
continue;
}
hydratedProviders[providerId][modelId] = model;
// Only the ignored internal data is grouped by API for type derivation. Public JSON catalog output stays flat.
const generatedDataProviders: Record<string, Record<string, Record<string, Model<Api>>>> = {};
const modelDataStructure: ModelDataStructure = {};
for (const providerId of generatedDataProviderIds) {
const models = jsonProviders[providerId];
generatedDataProviders[providerId] = {};
modelDataStructure[providerId] = {};
const apiIds = Array.from(new Set(Object.values(models).map((model) => model.api))).sort();
for (const api of apiIds) {
generatedDataProviders[providerId][api] = {};
for (const [modelId, model] of Object.entries(models)) {
if (model.api !== api) continue;
generatedDataProviders[providerId][api][modelId] = model;
modelDataStructure[providerId][modelId] = api;
}
}
if (hydrationErrors.length > 0) {
throw new Error(`Cannot hydrate the committed model catalog:\n${hydrationErrors.map((error) => ` - ${error}`).join("\n")}`);
}
generatedDataProviders = hydratedProviders;
}
if (!generatorOptions.jsonOnly) {
@@ -2526,7 +2515,7 @@ async function generateModels() {
const stagingRoot = mkdtempSync(join(providersDir, ".model-generation-"));
const stagedDataDir = join(stagingRoot, "data");
const previousDataDir = join(stagingRoot, "previous-data");
let restoreStructuralCatalog: (() => void) | undefined;
let restoreGeneratedCatalog: (() => void) | undefined;
try {
mkdirSync(stagedDataDir, { recursive: true });
const fileContents: Record<string, string> = {};
@@ -2543,7 +2532,6 @@ async function generateModels() {
validateModelDataDirectory(modelDataStructure, stagedDataDir);
if (!generatorOptions.dataOnly) {
// Generate TypeScript structural catalogs only after the model data is complete and valid.
const previousShardContents = new Map(
readdirSync(providersDir)
.filter((entry) => entry.endsWith(".models.ts"))
@@ -2551,7 +2539,7 @@ async function generateModels() {
);
const aggregatorPath = join(packageRoot, "src/models.generated.ts");
const previousAggregator = readFileSync(aggregatorPath, "utf8");
restoreStructuralCatalog = () => {
restoreGeneratedCatalog = () => {
for (const entry of readdirSync(providersDir)) {
if (entry.endsWith(".models.ts")) rmSync(join(providersDir, entry));
}
@@ -2568,21 +2556,12 @@ async function generateModels() {
const catalogConstName = (providerId: string) =>
`${providerId.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_MODELS`;
const generatedShardFiles = new Set<string>();
function emitModelShape(model: Model<any>, indent: string): string {
return `${indent}${JSON.stringify(model.id)}: Model<${JSON.stringify(model.api)}> & {\n${indent}\tid: ${JSON.stringify(model.id)};\n${indent}\tprovider: ${JSON.stringify(model.provider)};\n${indent}};\n`;
}
for (const providerId of sortedProviderIds) {
const models = providers[providerId];
let output = generatedHeader;
output += `import values from "./data/${providerId}.json" with { type: "json" };\n`;
output += `import type { Model } from "../types.ts";\n\n`;
output += `export const ${catalogConstName(providerId)} = values as {\n`;
for (const modelId of Object.keys(models).sort()) {
output += emitModelShape(models[modelId], "\t");
}
output += `};\n`;
output += `import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";\n\n`;
output += `export const ${catalogConstName(providerId)}: ModelCatalog<typeof values, ${JSON.stringify(providerId)}> =\n`;
output += `\tflattenModelCatalog(${JSON.stringify(providerId)}, values);\n`;
const filename = `${providerId}.models.ts`;
generatedShardFiles.add(filename);
writeFileSync(join(providersDir, filename), output);
@@ -2590,19 +2569,22 @@ async function generateModels() {
for (const entry of readdirSync(providersDir)) {
if (entry.endsWith(".models.ts") && !generatedShardFiles.has(entry)) rmSync(join(providersDir, entry));
}
console.log(`Generated ${sortedProviderIds.length} catalog structures under src/providers/`);
let output = generatedHeader;
for (const providerId of sortedProviderIds) {
output += `import { ${catalogConstName(providerId)} } from "./providers/${providerId}.models.ts";\n`;
}
output += `\nexport const MODELS = {\n`;
output += `\nexport const MODELS: {\n`;
for (const providerId of sortedProviderIds) {
output += `\treadonly ${JSON.stringify(providerId)}: typeof ${catalogConstName(providerId)};\n`;
}
output += `} = {\n`;
for (const providerId of sortedProviderIds) {
output += `\t${JSON.stringify(providerId)}: ${catalogConstName(providerId)},\n`;
}
output += `} as const;\n`;
output += `};\n`;
writeFileSync(aggregatorPath, output);
console.log("Generated src/models.generated.ts");
console.log("Generated provider catalogs and src/models.generated.ts");
}
const hadPreviousData = existsSync(dataDir);
@@ -2615,14 +2597,14 @@ async function generateModels() {
if (hadPreviousData && existsSync(previousDataDir)) renameSync(previousDataDir, dataDir);
throw error;
}
restoreStructuralCatalog = undefined;
restoreGeneratedCatalog = undefined;
console.log(
generatorOptions.dataOnly
? "Hydrated JSON model values under src/providers/data/"
: "Generated JSON model values under src/providers/data/",
);
} catch (error) {
restoreStructuralCatalog?.();
restoreGeneratedCatalog?.();
throw error;
} finally {
rmSync(stagingRoot, { recursive: true, force: true });
+105 -99
View File
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
export const MODEL_DATA_SCHEMA_VERSION = 1;
export const MODEL_DATA_SCHEMA_VERSION = 2;
export const MODEL_DATA_MANIFEST_FILE = ".manifest.json";
export type ModelDataStructure = Record<string, Record<string, string>>;
@@ -13,21 +13,13 @@ export interface ModelDataManifest {
files: Record<string, string>;
}
const JSON_STRING_PATTERN = '"(?:\\\\.|[^"\\\\])*"';
const MODEL_SHAPE_PATTERN = new RegExp(`^\\t(${JSON_STRING_PATTERN}): Model<(${JSON_STRING_PATTERN})> & \\{$`);
const MODEL_ID_PATTERN = new RegExp(`^\\t\\tid: (${JSON_STRING_PATTERN});$`);
const MODEL_PROVIDER_PATTERN = new RegExp(`^\\t\\tprovider: (${JSON_STRING_PATTERN});$`);
const MODEL_DATA_IMPORT_PATTERN =
/^import \{ [A-Z][A-Z0-9_]*_MODELS \} from "\.\/providers\/([^"/]+)\.models\.ts";$/gm;
function sha256(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function parseJsonString(value: string, description: string): string {
const parsed: unknown = JSON.parse(value);
if (typeof parsed !== "string") throw new Error(`${description} is not a string`);
return parsed;
}
function sortedRecord<T>(entries: Iterable<readonly [string, T]>): Record<string, T> {
return Object.fromEntries(Array.from(entries).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
}
@@ -46,84 +38,6 @@ function describeSetDifference(expected: readonly string[], actual: readonly str
.join("; ");
}
function parseProviderStructure(path: string, providerId: string): Record<string, string> {
const source = readFileSync(path, "utf8");
const expectedImport = `import values from "./data/${providerId}.json" with { type: "json" };`;
if (!source.includes(expectedImport)) {
throw new Error(`${path} does not import ${providerId}.json`);
}
const models = new Map<string, string>();
const lines = source.split("\n");
for (let index = 0; index < lines.length; index++) {
const shapeMatch = MODEL_SHAPE_PATTERN.exec(lines[index]);
if (!shapeMatch) continue;
const idMatch = MODEL_ID_PATTERN.exec(lines[index + 1] ?? "");
const providerMatch = MODEL_PROVIDER_PATTERN.exec(lines[index + 2] ?? "");
if (!idMatch || !providerMatch || lines[index + 3] !== "\t};") {
throw new Error(`${path}:${index + 1} has a malformed generated model declaration`);
}
const key = parseJsonString(shapeMatch[1], `${path}:${index + 1} model key`);
const api = parseJsonString(shapeMatch[2], `${path}:${index + 1} model API`);
const id = parseJsonString(idMatch[1], `${path}:${index + 2} model ID`);
const provider = parseJsonString(providerMatch[1], `${path}:${index + 3} provider ID`);
if (id !== key) throw new Error(`${path}:${index + 1} declares key ${key} with ID ${id}`);
if (provider !== providerId) {
throw new Error(`${path}:${index + 1} declares provider ${provider} instead of ${providerId}`);
}
if (models.has(key)) throw new Error(`${path} declares model ${key} more than once`);
models.set(key, api);
index += 3;
}
if (models.size === 0) throw new Error(`${path} contains no generated model declarations`);
return sortedRecord(models);
}
export function readModelDataStructure(packageRoot: string): ModelDataStructure {
const providersDir = join(packageRoot, "src", "providers");
const shardProviderIds = readdirSync(providersDir)
.filter((entry) => entry.endsWith(".models.ts"))
.map((entry) => entry.slice(0, -".models.ts".length))
.sort();
if (shardProviderIds.length === 0) throw new Error(`No generated provider shards found under ${providersDir}`);
const aggregator = readFileSync(join(packageRoot, "src", "models.generated.ts"), "utf8");
const importedProviderIds = Array.from(
aggregator.matchAll(/^import \{ [A-Z0-9_]+_MODELS \} from "\.\/providers\/([^"/]+)\.models\.ts";$/gm),
(match) => match[1],
).sort();
if (!sameStrings(shardProviderIds, importedProviderIds)) {
throw new Error(
`Generated model aggregator and provider shards do not match (${describeSetDifference(shardProviderIds, importedProviderIds)})`,
);
}
return sortedRecord(
shardProviderIds.map((providerId) => [
providerId,
parseProviderStructure(join(providersDir, `${providerId}.models.ts`), providerId),
] as const),
);
}
export function modelDataStructureHash(structure: ModelDataStructure): string {
return sha256(JSON.stringify(structure));
}
export function createModelDataManifest(
structure: ModelDataStructure,
fileContents: Readonly<Record<string, string>>,
): ModelDataManifest {
return {
schemaVersion: MODEL_DATA_SCHEMA_VERSION,
structureHash: modelDataStructureHash(structure),
files: sortedRecord(Object.entries(fileContents).map(([file, content]) => [file, sha256(content)] as const)),
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -143,6 +57,76 @@ function readJsonObject(path: string, description: string, errors: string[]): Re
return parsed;
}
function readProviderStructure(path: string, providerId: string): Record<string, string> {
const errors: string[] = [];
const groups = readJsonObject(path, `${providerId}.json`, errors);
if (!groups) throw new Error(errors.join("\n"));
const models = new Map<string, string>();
for (const [api, value] of Object.entries(groups)) {
if (!isRecord(value)) throw new Error(`${path} API group ${JSON.stringify(api)} must be an object`);
for (const modelId of Object.keys(value)) {
if (models.has(modelId)) throw new Error(`${path} contains model ${modelId} in more than one API group`);
models.set(modelId, api);
}
}
if (models.size === 0) throw new Error(`${path} contains no generated model data`);
return sortedRecord(models);
}
export function readModelDataProviderIds(packageRoot: string): string[] {
const aggregatorPath = join(packageRoot, "src", "models.generated.ts");
const aggregator = readFileSync(aggregatorPath, "utf8");
const providerIds = Array.from(aggregator.matchAll(MODEL_DATA_IMPORT_PATTERN), (match) => match[1]).sort();
if (providerIds.length === 0) throw new Error(`No generated provider imports found in ${aggregatorPath}`);
if (new Set(providerIds).size !== providerIds.length) {
throw new Error(`Generated model aggregator contains duplicate provider imports: ${aggregatorPath}`);
}
return providerIds;
}
export function readModelDataStructure(packageRoot: string): ModelDataStructure {
const providersDir = join(packageRoot, "src", "providers");
const dataDir = join(providersDir, "data");
const providerIds = readModelDataProviderIds(packageRoot);
const expectedShards = providerIds.map((providerId) => `${providerId}.models.ts`).sort();
const actualShards = readdirSync(providersDir)
.filter((entry) => entry.endsWith(".models.ts"))
.sort();
if (!sameStrings(expectedShards, actualShards)) {
throw new Error(
`Generated model aggregator and provider shards do not match (${describeSetDifference(expectedShards, actualShards)})`,
);
}
return sortedRecord(
providerIds.map((providerId) => [
providerId,
readProviderStructure(join(dataDir, `${providerId}.json`), providerId),
]),
);
}
export function modelDataStructureHash(structure: ModelDataStructure): string {
const normalized = sortedRecord(
Object.entries(structure).map(
([providerId, models]) => [providerId, sortedRecord(Object.entries(models))] as const,
),
);
return sha256(JSON.stringify(normalized));
}
export function createModelDataManifest(
structure: ModelDataStructure,
fileContents: Readonly<Record<string, string>>,
): ModelDataManifest {
return {
schemaVersion: MODEL_DATA_SCHEMA_VERSION,
structureHash: modelDataStructureHash(structure),
files: sortedRecord(Object.entries(fileContents).map(([file, content]) => [file, sha256(content)] as const)),
};
}
function validateModelValue(
value: unknown,
providerId: string,
@@ -209,7 +193,7 @@ export function validateModelDataDirectory(structure: ModelDataStructure, dataDi
.filter((entry) => entry.endsWith(".json") && entry !== MODEL_DATA_MANIFEST_FILE)
.sort();
if (!sameStrings(expectedFiles, actualFiles)) {
errors.push(`provider data files do not match the structural catalog (${describeSetDifference(expectedFiles, actualFiles)})`);
errors.push(`provider data files do not match the generated catalog (${describeSetDifference(expectedFiles, actualFiles)})`);
}
const manifestPath = join(dataDir, MODEL_DATA_MANIFEST_FILE);
@@ -221,7 +205,7 @@ export function validateModelDataDirectory(structure: ModelDataStructure, dataDi
}
const expectedStructureHash = modelDataStructureHash(structure);
if (manifest?.structureHash !== expectedStructureHash) {
errors.push("model data generation stamp does not match the structural catalog");
errors.push("model data generation stamp does not match the generated catalog");
}
const manifestFiles = isRecord(manifest?.files) ? manifest.files : undefined;
if (!manifestFiles) errors.push("model data manifest has no file hashes");
@@ -240,15 +224,37 @@ export function validateModelDataDirectory(structure: ModelDataStructure, dataDi
if (manifestFiles && manifestFiles[filename] !== sha256(content)) {
errors.push(`${filename} does not match its manifest hash`);
}
const values = readJsonObject(path, filename, errors);
if (!values) continue;
const expectedModelIds = Object.keys(expectedModels).sort();
const actualModelIds = Object.keys(values).sort();
if (!sameStrings(expectedModelIds, actualModelIds)) {
errors.push(`${filename} model IDs do not match the structural catalog (${describeSetDifference(expectedModelIds, actualModelIds)})`);
const groups = readJsonObject(path, filename, errors);
if (!groups) continue;
const actualModels = new Map<string, string>();
for (const [api, value] of Object.entries(groups)) {
if (!isRecord(value)) {
errors.push(`${filename} API group ${JSON.stringify(api)} must be an object`);
continue;
}
for (const [modelId, model] of Object.entries(value)) {
if (actualModels.has(modelId)) {
errors.push(`${providerId}/${modelId} appears in more than one API group`);
continue;
}
actualModels.set(modelId, api);
validateModelValue(model, providerId, modelId, api, errors);
}
}
for (const [modelId, api] of Object.entries(expectedModels)) {
if (modelId in values) validateModelValue(values[modelId], providerId, modelId, api, errors);
const expectedModelIds = Object.keys(expectedModels).sort();
const actualModelIds = Array.from(actualModels.keys()).sort();
if (!sameStrings(expectedModelIds, actualModelIds)) {
errors.push(`${filename} model IDs do not match the generated catalog (${describeSetDifference(expectedModelIds, actualModelIds)})`);
}
for (const [modelId, expectedApi] of Object.entries(expectedModels)) {
const actualApi = actualModels.get(modelId);
if (actualApi !== undefined && actualApi !== expectedApi) {
errors.push(
`${providerId}/${modelId} is grouped under API ${JSON.stringify(actualApi)}, expected ${JSON.stringify(expectedApi)}`,
);
}
}
}
+6
View File
@@ -15,6 +15,7 @@ type OAuthFlowLoaders = {
anthropic: () => OAuthAuth | Promise<OAuthAuth>;
openaiCodex: () => OAuthAuth | Promise<OAuthAuth>;
githubCopilot: () => OAuthAuth | Promise<OAuthAuth>;
openrouter: () => OAuthAuth | Promise<OAuthAuth>;
kimiCoding: () => OAuthAuth | Promise<OAuthAuth>;
xai: () => OAuthAuth | Promise<OAuthAuth>;
radius: (options: { name: string; gateway: string }) => OAuthAuth | Promise<OAuthAuth>;
@@ -42,6 +43,11 @@ export const loadGitHubCopilotOAuth = async (): Promise<OAuthAuth> => {
return ((await importOAuthModule("./github-copilot.ts")) as { githubCopilotOAuth: OAuthAuth }).githubCopilotOAuth;
};
export const loadOpenRouterOAuth = async (): Promise<OAuthAuth> => {
if (bundledLoaders) return bundledLoaders.openrouter();
return ((await importOAuthModule("./openrouter.ts")) as { openRouterOAuth: OAuthAuth }).openRouterOAuth;
};
export const loadKimiCodingOAuth = async (): Promise<OAuthAuth> => {
if (bundledLoaders) return bundledLoaders.kimiCoding();
return ((await importOAuthModule("./kimi-coding.ts")) as { kimiCodingOAuth: OAuthAuth }).kimiCodingOAuth;
+246
View File
@@ -0,0 +1,246 @@
/**
* OpenRouter OAuth PKCE flow.
*
* OpenRouter exchanges an authorization code for a permanent, user-controlled
* API key rather than an expiring access/refresh token pair. The callback is
* handled by a one-shot loopback server on an ephemeral port.
*
* NOTE: This module uses Node.js http.createServer for the OAuth callback server.
* It is only intended for CLI use, not browser environments.
*/
import { createServer, type Server, type ServerResponse } from "node:http";
import { getProviderEnvValue } from "../../utils/provider-env.ts";
import type { AuthInteraction, OAuthAuth, OAuthCredential } from "../types.ts";
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
import { generatePKCE } from "./pkce.ts";
const AUTHORIZE_URL = "https://openrouter.ai/auth";
const TOKEN_URL = "https://openrouter.ai/api/v1/auth/keys";
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
const TOKEN_EXCHANGE_TIMEOUT_MS = 30_000;
function getCallbackHost(): string {
return getProviderEnvValue("PI_OAUTH_CALLBACK_HOST") || "127.0.0.1";
}
type JsonObject = Record<string, unknown>;
type OpenRouterCallbackServer = {
callbackUrl: string;
credential: Promise<OAuthCredential>;
close(): void;
};
function sendHtml(response: ServerResponse, status: number, html: string): void {
response.statusCode = status;
response.setHeader("content-type", "text/html; charset=utf-8");
response.setHeader("cache-control", "no-store");
response.end(html);
}
function errorDetail(body: JsonObject): string | undefined {
if (typeof body.error_description === "string") return body.error_description;
if (typeof body.message === "string") return body.message;
if (typeof body.error === "string") return body.error;
if (body.error && typeof body.error === "object" && !Array.isArray(body.error)) {
const message = (body.error as JsonObject).message;
if (typeof message === "string") return message;
}
return undefined;
}
async function exchangeAuthorizationCode(
code: string,
verifier: string,
signal?: AbortSignal,
): Promise<OAuthCredential> {
if (signal?.aborted) throw new Error("Login cancelled");
const controller = new AbortController();
const onAbort = () => controller.abort(signal?.reason);
signal?.addEventListener("abort", onAbort, { once: true });
const timeout = setTimeout(
() => controller.abort(new Error("OpenRouter OAuth token exchange timed out")),
TOKEN_EXCHANGE_TIMEOUT_MS,
);
let response: Response;
let body: JsonObject = {};
try {
response = await fetch(TOKEN_URL, {
method: "POST",
headers: { accept: "application/json", "content-type": "application/json" },
body: JSON.stringify({ code, code_verifier: verifier, code_challenge_method: "S256" }),
signal: controller.signal,
});
try {
const parsed = (await response.json()) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) body = parsed as JsonObject;
} catch {
if (response.ok) throw new Error("OpenRouter OAuth returned invalid JSON");
}
} catch (error) {
if (signal?.aborted) throw new Error("Login cancelled");
if (controller.signal.aborted) throw new Error("OpenRouter OAuth token exchange timed out");
throw error;
} finally {
clearTimeout(timeout);
signal?.removeEventListener("abort", onAbort);
}
if (!response.ok) {
const detail = errorDetail(body);
throw new Error(`OpenRouter OAuth key exchange failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}`);
}
if (typeof body.key !== "string" || body.key.length === 0) {
throw new Error('OpenRouter OAuth response carries no "key"');
}
return {
type: "oauth",
access: body.key,
refresh: "",
expires: Number.MAX_SAFE_INTEGER,
};
}
async function startCallbackServer(
callbackPath: string,
verifier: string,
signal?: AbortSignal,
): Promise<OpenRouterCallbackServer> {
if (signal?.aborted) throw new Error("Login cancelled");
const callbackHost = getCallbackHost();
let resolveCredential: (credential: OAuthCredential) => void = () => {};
let rejectCredential: (error: Error) => void = () => {};
const credential = new Promise<OAuthCredential>((resolve, reject) => {
resolveCredential = resolve;
rejectCredential = reject;
});
let server: Server;
let claimed = false;
let settled = false;
let timeout: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;
const finish = (result: { credential: OAuthCredential } | { error: Error }): void => {
if (settled) return;
settled = true;
if (timeout) clearTimeout(timeout);
if (onAbort) signal?.removeEventListener("abort", onAbort);
server.close();
if ("credential" in result) resolveCredential(result.credential);
else rejectCredential(result.error);
};
server = createServer((request, response) => {
void (async () => {
const requestUrl = new URL(request.url ?? "/", `http://${callbackHost}`);
if (request.method !== "GET" || requestUrl.pathname !== callbackPath) {
sendHtml(response, 404, oauthErrorHtml("OAuth callback route not found."));
return;
}
if (claimed || settled) {
sendHtml(response, 409, oauthErrorHtml("This OAuth callback has already been used."));
return;
}
const oauthError = requestUrl.searchParams.get("error");
if (oauthError) {
const description = requestUrl.searchParams.get("error_description") ?? oauthError;
sendHtml(response, 400, oauthErrorHtml("OpenRouter authorization was denied.", description));
finish({ error: new Error(`OpenRouter authorization failed: ${description}`) });
return;
}
const code = requestUrl.searchParams.get("code");
if (!code) {
sendHtml(response, 400, oauthErrorHtml("OpenRouter returned no authorization code."));
return;
}
claimed = true;
try {
const result = await exchangeAuthorizationCode(code, verifier, signal);
sendHtml(response, 200, oauthSuccessHtml("Signed in to OpenRouter. You may now close this page."));
finish({ credential: result });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown token exchange error";
sendHtml(response, 502, oauthErrorHtml("OpenRouter key exchange failed.", message));
finish({ error: error instanceof Error ? error : new Error(message) });
}
})();
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, callbackHost, () => {
server.removeListener("error", reject);
resolve();
});
});
server.on("error", (error) => finish({ error }));
onAbort = () => finish({ error: new Error("Login cancelled") });
signal?.addEventListener("abort", onAbort, { once: true });
if (signal?.aborted) {
signal.removeEventListener("abort", onAbort);
server.close();
throw new Error("Login cancelled");
}
timeout = setTimeout(() => finish({ error: new Error("OpenRouter OAuth login timed out") }), LOGIN_TIMEOUT_MS);
const address = server.address();
if (!address || typeof address === "string") {
finish({ error: new Error("Could not determine the OpenRouter OAuth callback port") });
throw new Error("Could not determine the OpenRouter OAuth callback port");
}
return {
callbackUrl: `http://${callbackHost}:${address.port}${callbackPath}`,
credential,
close: () => finish({ error: new Error("Login cancelled") }),
};
}
async function loginOpenRouter(interaction: AuthInteraction): Promise<OAuthCredential> {
const { verifier, challenge } = await generatePKCE();
const callbackPath = `/oauth/callback/${crypto.randomUUID()}`;
const callback = await startCallbackServer(callbackPath, verifier, interaction.signal);
const authorizeUrl = new URL(AUTHORIZE_URL);
authorizeUrl.search = new URLSearchParams({
callback_url: callback.callbackUrl,
code_challenge: challenge,
code_challenge_method: "S256",
}).toString();
interaction.notify({
type: "progress",
message: `Listening for OpenRouter OAuth callback on ${callback.callbackUrl}`,
});
interaction.notify({
type: "auth_url",
url: authorizeUrl.toString(),
instructions: "Complete sign-in in your browser.",
});
try {
return await callback.credential;
} finally {
callback.close();
}
}
export const openRouterOAuth: OAuthAuth = {
name: "OpenRouter OAuth",
loginLabel: "Sign in with OpenRouter",
login: loginOpenRouter,
async refresh(credential) {
return credential;
},
async toAuth(credential) {
return { apiKey: credential.access };
},
};
+2
View File
@@ -3,6 +3,7 @@ import { githubCopilotOAuth } from "./auth/oauth/github-copilot.ts";
import { kimiCodingOAuth } from "./auth/oauth/kimi-coding.ts";
import { registerBundledOAuthFlowLoaders } from "./auth/oauth/load.ts";
import { openaiCodexOAuth } from "./auth/oauth/openai-codex.ts";
import { openRouterOAuth } from "./auth/oauth/openrouter.ts";
import { createRadiusOAuth } from "./auth/oauth/radius.ts";
import { xaiOAuth } from "./auth/oauth/xai.ts";
@@ -12,6 +13,7 @@ export function registerBunOAuthFlows(): void {
anthropic: () => anthropicOAuth,
openaiCodex: () => openaiCodexOAuth,
githubCopilot: () => githubCopilotOAuth,
openrouter: () => openRouterOAuth,
kimiCoding: () => kimiCodingOAuth,
xai: () => xaiOAuth,
radius: createRadiusOAuth,
+27
View File
@@ -0,0 +1,27 @@
import type { Api, Model, ProviderId } from "./types.ts";
export type ModelGroups = Record<string, Record<string, object>>;
type ModelId<TGroups extends ModelGroups> = {
[TApi in keyof TGroups]: keyof TGroups[TApi];
}[keyof TGroups] &
string;
type ModelApi<TGroups extends ModelGroups, TModelId extends ModelId<TGroups>> = {
[TApi in keyof TGroups]: TModelId extends keyof TGroups[TApi] ? TApi : never;
}[keyof TGroups] &
Api;
export type ModelCatalog<TGroups extends ModelGroups, TProvider extends ProviderId> = {
[TModelId in ModelId<TGroups>]: Model<ModelApi<TGroups, TModelId>> & {
id: TModelId;
provider: TProvider;
};
};
export function flattenModelCatalog<const TProvider extends ProviderId, const TGroups extends ModelGroups>(
_provider: TProvider,
groups: TGroups,
): ModelCatalog<TGroups, TProvider> {
return Object.assign({}, ...Object.values(groups)) as ModelCatalog<TGroups, TProvider>;
}
+40 -2
View File
@@ -39,7 +39,45 @@ import { XIAOMI_TOKEN_PLAN_SGP_MODELS } from "./providers/xiaomi-token-plan-sgp.
import { ZAI_MODELS } from "./providers/zai.models.ts";
import { ZAI_CODING_CN_MODELS } from "./providers/zai-coding-cn.models.ts";
export const MODELS = {
export const MODELS: {
readonly "amazon-bedrock": typeof AMAZON_BEDROCK_MODELS;
readonly "ant-ling": typeof ANT_LING_MODELS;
readonly "anthropic": typeof ANTHROPIC_MODELS;
readonly "azure-openai-responses": typeof AZURE_OPENAI_RESPONSES_MODELS;
readonly "cerebras": typeof CEREBRAS_MODELS;
readonly "cloudflare-ai-gateway": typeof CLOUDFLARE_AI_GATEWAY_MODELS;
readonly "cloudflare-workers-ai": typeof CLOUDFLARE_WORKERS_AI_MODELS;
readonly "deepseek": typeof DEEPSEEK_MODELS;
readonly "fireworks": typeof FIREWORKS_MODELS;
readonly "github-copilot": typeof GITHUB_COPILOT_MODELS;
readonly "google": typeof GOOGLE_MODELS;
readonly "google-vertex": typeof GOOGLE_VERTEX_MODELS;
readonly "groq": typeof GROQ_MODELS;
readonly "huggingface": typeof HUGGINGFACE_MODELS;
readonly "kimi-coding": typeof KIMI_CODING_MODELS;
readonly "minimax": typeof MINIMAX_MODELS;
readonly "minimax-cn": typeof MINIMAX_CN_MODELS;
readonly "mistral": typeof MISTRAL_MODELS;
readonly "moonshotai": typeof MOONSHOTAI_MODELS;
readonly "moonshotai-cn": typeof MOONSHOTAI_CN_MODELS;
readonly "nvidia": typeof NVIDIA_MODELS;
readonly "openai": typeof OPENAI_MODELS;
readonly "openai-codex": typeof OPENAI_CODEX_MODELS;
readonly "opencode": typeof OPENCODE_MODELS;
readonly "opencode-go": typeof OPENCODE_GO_MODELS;
readonly "openrouter": typeof OPENROUTER_MODELS;
readonly "qwen-token-plan": typeof QWEN_TOKEN_PLAN_MODELS;
readonly "qwen-token-plan-cn": typeof QWEN_TOKEN_PLAN_CN_MODELS;
readonly "together": typeof TOGETHER_MODELS;
readonly "vercel-ai-gateway": typeof VERCEL_AI_GATEWAY_MODELS;
readonly "xai": typeof XAI_MODELS;
readonly "xiaomi": typeof XIAOMI_MODELS;
readonly "xiaomi-token-plan-ams": typeof XIAOMI_TOKEN_PLAN_AMS_MODELS;
readonly "xiaomi-token-plan-cn": typeof XIAOMI_TOKEN_PLAN_CN_MODELS;
readonly "xiaomi-token-plan-sgp": typeof XIAOMI_TOKEN_PLAN_SGP_MODELS;
readonly "zai": typeof ZAI_MODELS;
readonly "zai-coding-cn": typeof ZAI_CODING_CN_MODELS;
} = {
"amazon-bedrock": AMAZON_BEDROCK_MODELS,
"ant-ling": ANT_LING_MODELS,
"anthropic": ANTHROPIC_MODELS,
@@ -77,4 +115,4 @@ export const MODELS = {
"xiaomi-token-plan-sgp": XIAOMI_TOKEN_PLAN_SGP_MODELS,
"zai": ZAI_MODELS,
"zai-coding-cn": ZAI_CODING_CN_MODELS,
} as const;
};
@@ -2,443 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/amazon-bedrock.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const AMAZON_BEDROCK_MODELS = values as {
"amazon.nova-2-lite-v1:0": Model<"bedrock-converse-stream"> & {
id: "amazon.nova-2-lite-v1:0";
provider: "amazon-bedrock";
};
"amazon.nova-lite-v1:0": Model<"bedrock-converse-stream"> & {
id: "amazon.nova-lite-v1:0";
provider: "amazon-bedrock";
};
"amazon.nova-micro-v1:0": Model<"bedrock-converse-stream"> & {
id: "amazon.nova-micro-v1:0";
provider: "amazon-bedrock";
};
"amazon.nova-pro-v1:0": Model<"bedrock-converse-stream"> & {
id: "amazon.nova-pro-v1:0";
provider: "amazon-bedrock";
};
"anthropic.claude-fable-5": Model<"bedrock-converse-stream"> & {
id: "anthropic.claude-fable-5";
provider: "amazon-bedrock";
};
"anthropic.claude-haiku-4-5-20251001-v1:0": Model<"bedrock-converse-stream"> & {
id: "anthropic.claude-haiku-4-5-20251001-v1:0";
provider: "amazon-bedrock";
};
"anthropic.claude-opus-4-1-20250805-v1:0": Model<"bedrock-converse-stream"> & {
id: "anthropic.claude-opus-4-1-20250805-v1:0";
provider: "amazon-bedrock";
};
"anthropic.claude-opus-4-5-20251101-v1:0": Model<"bedrock-converse-stream"> & {
id: "anthropic.claude-opus-4-5-20251101-v1:0";
provider: "amazon-bedrock";
};
"anthropic.claude-opus-4-6-v1": Model<"bedrock-converse-stream"> & {
id: "anthropic.claude-opus-4-6-v1";
provider: "amazon-bedrock";
};
"anthropic.claude-opus-4-7": Model<"bedrock-converse-stream"> & {
id: "anthropic.claude-opus-4-7";
provider: "amazon-bedrock";
};
"anthropic.claude-opus-4-8": Model<"bedrock-converse-stream"> & {
id: "anthropic.claude-opus-4-8";
provider: "amazon-bedrock";
};
"anthropic.claude-sonnet-4-5-20250929-v1:0": Model<"bedrock-converse-stream"> & {
id: "anthropic.claude-sonnet-4-5-20250929-v1:0";
provider: "amazon-bedrock";
};
"anthropic.claude-sonnet-4-6": Model<"bedrock-converse-stream"> & {
id: "anthropic.claude-sonnet-4-6";
provider: "amazon-bedrock";
};
"anthropic.claude-sonnet-5": Model<"bedrock-converse-stream"> & {
id: "anthropic.claude-sonnet-5";
provider: "amazon-bedrock";
};
"au.anthropic.claude-haiku-4-5-20251001-v1:0": Model<"bedrock-converse-stream"> & {
id: "au.anthropic.claude-haiku-4-5-20251001-v1:0";
provider: "amazon-bedrock";
};
"au.anthropic.claude-opus-4-6-v1": Model<"bedrock-converse-stream"> & {
id: "au.anthropic.claude-opus-4-6-v1";
provider: "amazon-bedrock";
};
"au.anthropic.claude-opus-4-8": Model<"bedrock-converse-stream"> & {
id: "au.anthropic.claude-opus-4-8";
provider: "amazon-bedrock";
};
"au.anthropic.claude-sonnet-4-5-20250929-v1:0": Model<"bedrock-converse-stream"> & {
id: "au.anthropic.claude-sonnet-4-5-20250929-v1:0";
provider: "amazon-bedrock";
};
"au.anthropic.claude-sonnet-4-6": Model<"bedrock-converse-stream"> & {
id: "au.anthropic.claude-sonnet-4-6";
provider: "amazon-bedrock";
};
"au.anthropic.claude-sonnet-5": Model<"bedrock-converse-stream"> & {
id: "au.anthropic.claude-sonnet-5";
provider: "amazon-bedrock";
};
"deepseek.r1-v1:0": Model<"bedrock-converse-stream"> & {
id: "deepseek.r1-v1:0";
provider: "amazon-bedrock";
};
"deepseek.v3-v1:0": Model<"bedrock-converse-stream"> & {
id: "deepseek.v3-v1:0";
provider: "amazon-bedrock";
};
"deepseek.v3.2": Model<"bedrock-converse-stream"> & {
id: "deepseek.v3.2";
provider: "amazon-bedrock";
};
"eu.anthropic.claude-fable-5": Model<"bedrock-converse-stream"> & {
id: "eu.anthropic.claude-fable-5";
provider: "amazon-bedrock";
};
"eu.anthropic.claude-haiku-4-5-20251001-v1:0": Model<"bedrock-converse-stream"> & {
id: "eu.anthropic.claude-haiku-4-5-20251001-v1:0";
provider: "amazon-bedrock";
};
"eu.anthropic.claude-opus-4-5-20251101-v1:0": Model<"bedrock-converse-stream"> & {
id: "eu.anthropic.claude-opus-4-5-20251101-v1:0";
provider: "amazon-bedrock";
};
"eu.anthropic.claude-opus-4-6-v1": Model<"bedrock-converse-stream"> & {
id: "eu.anthropic.claude-opus-4-6-v1";
provider: "amazon-bedrock";
};
"eu.anthropic.claude-opus-4-7": Model<"bedrock-converse-stream"> & {
id: "eu.anthropic.claude-opus-4-7";
provider: "amazon-bedrock";
};
"eu.anthropic.claude-opus-4-8": Model<"bedrock-converse-stream"> & {
id: "eu.anthropic.claude-opus-4-8";
provider: "amazon-bedrock";
};
"eu.anthropic.claude-sonnet-4-5-20250929-v1:0": Model<"bedrock-converse-stream"> & {
id: "eu.anthropic.claude-sonnet-4-5-20250929-v1:0";
provider: "amazon-bedrock";
};
"eu.anthropic.claude-sonnet-4-6": Model<"bedrock-converse-stream"> & {
id: "eu.anthropic.claude-sonnet-4-6";
provider: "amazon-bedrock";
};
"eu.anthropic.claude-sonnet-5": Model<"bedrock-converse-stream"> & {
id: "eu.anthropic.claude-sonnet-5";
provider: "amazon-bedrock";
};
"global.anthropic.claude-fable-5": Model<"bedrock-converse-stream"> & {
id: "global.anthropic.claude-fable-5";
provider: "amazon-bedrock";
};
"global.anthropic.claude-haiku-4-5-20251001-v1:0": Model<"bedrock-converse-stream"> & {
id: "global.anthropic.claude-haiku-4-5-20251001-v1:0";
provider: "amazon-bedrock";
};
"global.anthropic.claude-opus-4-5-20251101-v1:0": Model<"bedrock-converse-stream"> & {
id: "global.anthropic.claude-opus-4-5-20251101-v1:0";
provider: "amazon-bedrock";
};
"global.anthropic.claude-opus-4-6-v1": Model<"bedrock-converse-stream"> & {
id: "global.anthropic.claude-opus-4-6-v1";
provider: "amazon-bedrock";
};
"global.anthropic.claude-opus-4-7": Model<"bedrock-converse-stream"> & {
id: "global.anthropic.claude-opus-4-7";
provider: "amazon-bedrock";
};
"global.anthropic.claude-opus-4-8": Model<"bedrock-converse-stream"> & {
id: "global.anthropic.claude-opus-4-8";
provider: "amazon-bedrock";
};
"global.anthropic.claude-sonnet-4-5-20250929-v1:0": Model<"bedrock-converse-stream"> & {
id: "global.anthropic.claude-sonnet-4-5-20250929-v1:0";
provider: "amazon-bedrock";
};
"global.anthropic.claude-sonnet-4-6": Model<"bedrock-converse-stream"> & {
id: "global.anthropic.claude-sonnet-4-6";
provider: "amazon-bedrock";
};
"global.anthropic.claude-sonnet-5": Model<"bedrock-converse-stream"> & {
id: "global.anthropic.claude-sonnet-5";
provider: "amazon-bedrock";
};
"google.gemma-3-27b-it": Model<"bedrock-converse-stream"> & {
id: "google.gemma-3-27b-it";
provider: "amazon-bedrock";
};
"google.gemma-3-4b-it": Model<"bedrock-converse-stream"> & {
id: "google.gemma-3-4b-it";
provider: "amazon-bedrock";
};
"jp.anthropic.claude-haiku-4-5-20251001-v1:0": Model<"bedrock-converse-stream"> & {
id: "jp.anthropic.claude-haiku-4-5-20251001-v1:0";
provider: "amazon-bedrock";
};
"jp.anthropic.claude-opus-4-7": Model<"bedrock-converse-stream"> & {
id: "jp.anthropic.claude-opus-4-7";
provider: "amazon-bedrock";
};
"jp.anthropic.claude-opus-4-8": Model<"bedrock-converse-stream"> & {
id: "jp.anthropic.claude-opus-4-8";
provider: "amazon-bedrock";
};
"jp.anthropic.claude-sonnet-4-5-20250929-v1:0": Model<"bedrock-converse-stream"> & {
id: "jp.anthropic.claude-sonnet-4-5-20250929-v1:0";
provider: "amazon-bedrock";
};
"jp.anthropic.claude-sonnet-4-6": Model<"bedrock-converse-stream"> & {
id: "jp.anthropic.claude-sonnet-4-6";
provider: "amazon-bedrock";
};
"jp.anthropic.claude-sonnet-5": Model<"bedrock-converse-stream"> & {
id: "jp.anthropic.claude-sonnet-5";
provider: "amazon-bedrock";
};
"meta.llama3-1-70b-instruct-v1:0": Model<"bedrock-converse-stream"> & {
id: "meta.llama3-1-70b-instruct-v1:0";
provider: "amazon-bedrock";
};
"meta.llama3-1-8b-instruct-v1:0": Model<"bedrock-converse-stream"> & {
id: "meta.llama3-1-8b-instruct-v1:0";
provider: "amazon-bedrock";
};
"meta.llama3-3-70b-instruct-v1:0": Model<"bedrock-converse-stream"> & {
id: "meta.llama3-3-70b-instruct-v1:0";
provider: "amazon-bedrock";
};
"meta.llama4-maverick-17b-instruct-v1:0": Model<"bedrock-converse-stream"> & {
id: "meta.llama4-maverick-17b-instruct-v1:0";
provider: "amazon-bedrock";
};
"meta.llama4-scout-17b-instruct-v1:0": Model<"bedrock-converse-stream"> & {
id: "meta.llama4-scout-17b-instruct-v1:0";
provider: "amazon-bedrock";
};
"minimax.minimax-m2": Model<"bedrock-converse-stream"> & {
id: "minimax.minimax-m2";
provider: "amazon-bedrock";
};
"minimax.minimax-m2.1": Model<"bedrock-converse-stream"> & {
id: "minimax.minimax-m2.1";
provider: "amazon-bedrock";
};
"minimax.minimax-m2.5": Model<"bedrock-converse-stream"> & {
id: "minimax.minimax-m2.5";
provider: "amazon-bedrock";
};
"mistral.devstral-2-123b": Model<"bedrock-converse-stream"> & {
id: "mistral.devstral-2-123b";
provider: "amazon-bedrock";
};
"mistral.magistral-small-2509": Model<"bedrock-converse-stream"> & {
id: "mistral.magistral-small-2509";
provider: "amazon-bedrock";
};
"mistral.ministral-3-14b-instruct": Model<"bedrock-converse-stream"> & {
id: "mistral.ministral-3-14b-instruct";
provider: "amazon-bedrock";
};
"mistral.ministral-3-3b-instruct": Model<"bedrock-converse-stream"> & {
id: "mistral.ministral-3-3b-instruct";
provider: "amazon-bedrock";
};
"mistral.ministral-3-8b-instruct": Model<"bedrock-converse-stream"> & {
id: "mistral.ministral-3-8b-instruct";
provider: "amazon-bedrock";
};
"mistral.mistral-large-3-675b-instruct": Model<"bedrock-converse-stream"> & {
id: "mistral.mistral-large-3-675b-instruct";
provider: "amazon-bedrock";
};
"mistral.pixtral-large-2502-v1:0": Model<"bedrock-converse-stream"> & {
id: "mistral.pixtral-large-2502-v1:0";
provider: "amazon-bedrock";
};
"mistral.voxtral-mini-3b-2507": Model<"bedrock-converse-stream"> & {
id: "mistral.voxtral-mini-3b-2507";
provider: "amazon-bedrock";
};
"mistral.voxtral-small-24b-2507": Model<"bedrock-converse-stream"> & {
id: "mistral.voxtral-small-24b-2507";
provider: "amazon-bedrock";
};
"moonshot.kimi-k2-thinking": Model<"bedrock-converse-stream"> & {
id: "moonshot.kimi-k2-thinking";
provider: "amazon-bedrock";
};
"moonshotai.kimi-k2.5": Model<"bedrock-converse-stream"> & {
id: "moonshotai.kimi-k2.5";
provider: "amazon-bedrock";
};
"nvidia.nemotron-nano-12b-v2": Model<"bedrock-converse-stream"> & {
id: "nvidia.nemotron-nano-12b-v2";
provider: "amazon-bedrock";
};
"nvidia.nemotron-nano-3-30b": Model<"bedrock-converse-stream"> & {
id: "nvidia.nemotron-nano-3-30b";
provider: "amazon-bedrock";
};
"nvidia.nemotron-nano-9b-v2": Model<"bedrock-converse-stream"> & {
id: "nvidia.nemotron-nano-9b-v2";
provider: "amazon-bedrock";
};
"nvidia.nemotron-super-3-120b": Model<"bedrock-converse-stream"> & {
id: "nvidia.nemotron-super-3-120b";
provider: "amazon-bedrock";
};
"openai.gpt-5.4": Model<"bedrock-converse-stream"> & {
id: "openai.gpt-5.4";
provider: "amazon-bedrock";
};
"openai.gpt-5.5": Model<"bedrock-converse-stream"> & {
id: "openai.gpt-5.5";
provider: "amazon-bedrock";
};
"openai.gpt-5.6-luna": Model<"bedrock-converse-stream"> & {
id: "openai.gpt-5.6-luna";
provider: "amazon-bedrock";
};
"openai.gpt-5.6-sol": Model<"bedrock-converse-stream"> & {
id: "openai.gpt-5.6-sol";
provider: "amazon-bedrock";
};
"openai.gpt-5.6-terra": Model<"bedrock-converse-stream"> & {
id: "openai.gpt-5.6-terra";
provider: "amazon-bedrock";
};
"openai.gpt-oss-120b": Model<"bedrock-converse-stream"> & {
id: "openai.gpt-oss-120b";
provider: "amazon-bedrock";
};
"openai.gpt-oss-120b-1:0": Model<"bedrock-converse-stream"> & {
id: "openai.gpt-oss-120b-1:0";
provider: "amazon-bedrock";
};
"openai.gpt-oss-20b": Model<"bedrock-converse-stream"> & {
id: "openai.gpt-oss-20b";
provider: "amazon-bedrock";
};
"openai.gpt-oss-20b-1:0": Model<"bedrock-converse-stream"> & {
id: "openai.gpt-oss-20b-1:0";
provider: "amazon-bedrock";
};
"openai.gpt-oss-safeguard-120b": Model<"bedrock-converse-stream"> & {
id: "openai.gpt-oss-safeguard-120b";
provider: "amazon-bedrock";
};
"openai.gpt-oss-safeguard-20b": Model<"bedrock-converse-stream"> & {
id: "openai.gpt-oss-safeguard-20b";
provider: "amazon-bedrock";
};
"qwen.qwen3-235b-a22b-2507-v1:0": Model<"bedrock-converse-stream"> & {
id: "qwen.qwen3-235b-a22b-2507-v1:0";
provider: "amazon-bedrock";
};
"qwen.qwen3-32b-v1:0": Model<"bedrock-converse-stream"> & {
id: "qwen.qwen3-32b-v1:0";
provider: "amazon-bedrock";
};
"qwen.qwen3-coder-30b-a3b-v1:0": Model<"bedrock-converse-stream"> & {
id: "qwen.qwen3-coder-30b-a3b-v1:0";
provider: "amazon-bedrock";
};
"qwen.qwen3-coder-480b-a35b-v1:0": Model<"bedrock-converse-stream"> & {
id: "qwen.qwen3-coder-480b-a35b-v1:0";
provider: "amazon-bedrock";
};
"qwen.qwen3-coder-next": Model<"bedrock-converse-stream"> & {
id: "qwen.qwen3-coder-next";
provider: "amazon-bedrock";
};
"qwen.qwen3-next-80b-a3b": Model<"bedrock-converse-stream"> & {
id: "qwen.qwen3-next-80b-a3b";
provider: "amazon-bedrock";
};
"qwen.qwen3-vl-235b-a22b": Model<"bedrock-converse-stream"> & {
id: "qwen.qwen3-vl-235b-a22b";
provider: "amazon-bedrock";
};
"us.anthropic.claude-fable-5": Model<"bedrock-converse-stream"> & {
id: "us.anthropic.claude-fable-5";
provider: "amazon-bedrock";
};
"us.anthropic.claude-haiku-4-5-20251001-v1:0": Model<"bedrock-converse-stream"> & {
id: "us.anthropic.claude-haiku-4-5-20251001-v1:0";
provider: "amazon-bedrock";
};
"us.anthropic.claude-opus-4-1-20250805-v1:0": Model<"bedrock-converse-stream"> & {
id: "us.anthropic.claude-opus-4-1-20250805-v1:0";
provider: "amazon-bedrock";
};
"us.anthropic.claude-opus-4-5-20251101-v1:0": Model<"bedrock-converse-stream"> & {
id: "us.anthropic.claude-opus-4-5-20251101-v1:0";
provider: "amazon-bedrock";
};
"us.anthropic.claude-opus-4-6-v1": Model<"bedrock-converse-stream"> & {
id: "us.anthropic.claude-opus-4-6-v1";
provider: "amazon-bedrock";
};
"us.anthropic.claude-opus-4-7": Model<"bedrock-converse-stream"> & {
id: "us.anthropic.claude-opus-4-7";
provider: "amazon-bedrock";
};
"us.anthropic.claude-opus-4-8": Model<"bedrock-converse-stream"> & {
id: "us.anthropic.claude-opus-4-8";
provider: "amazon-bedrock";
};
"us.anthropic.claude-sonnet-4-5-20250929-v1:0": Model<"bedrock-converse-stream"> & {
id: "us.anthropic.claude-sonnet-4-5-20250929-v1:0";
provider: "amazon-bedrock";
};
"us.anthropic.claude-sonnet-4-6": Model<"bedrock-converse-stream"> & {
id: "us.anthropic.claude-sonnet-4-6";
provider: "amazon-bedrock";
};
"us.anthropic.claude-sonnet-5": Model<"bedrock-converse-stream"> & {
id: "us.anthropic.claude-sonnet-5";
provider: "amazon-bedrock";
};
"us.deepseek.r1-v1:0": Model<"bedrock-converse-stream"> & {
id: "us.deepseek.r1-v1:0";
provider: "amazon-bedrock";
};
"us.meta.llama4-maverick-17b-instruct-v1:0": Model<"bedrock-converse-stream"> & {
id: "us.meta.llama4-maverick-17b-instruct-v1:0";
provider: "amazon-bedrock";
};
"us.meta.llama4-scout-17b-instruct-v1:0": Model<"bedrock-converse-stream"> & {
id: "us.meta.llama4-scout-17b-instruct-v1:0";
provider: "amazon-bedrock";
};
"writer.palmyra-x4-v1:0": Model<"bedrock-converse-stream"> & {
id: "writer.palmyra-x4-v1:0";
provider: "amazon-bedrock";
};
"writer.palmyra-x5-v1:0": Model<"bedrock-converse-stream"> & {
id: "writer.palmyra-x5-v1:0";
provider: "amazon-bedrock";
};
"xai.grok-4.3": Model<"bedrock-converse-stream"> & {
id: "xai.grok-4.3";
provider: "amazon-bedrock";
};
"zai.glm-4.7": Model<"bedrock-converse-stream"> & {
id: "zai.glm-4.7";
provider: "amazon-bedrock";
};
"zai.glm-4.7-flash": Model<"bedrock-converse-stream"> & {
id: "zai.glm-4.7-flash";
provider: "amazon-bedrock";
};
"zai.glm-5": Model<"bedrock-converse-stream"> & {
id: "zai.glm-5";
provider: "amazon-bedrock";
};
};
export const AMAZON_BEDROCK_MODELS: ModelCatalog<typeof values, "amazon-bedrock"> =
flattenModelCatalog("amazon-bedrock", values);
+3 -15
View File
@@ -2,19 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/ant-ling.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const ANT_LING_MODELS = values as {
"Ling-2.6-1T": Model<"openai-completions"> & {
id: "Ling-2.6-1T";
provider: "ant-ling";
};
"Ling-2.6-flash": Model<"openai-completions"> & {
id: "Ling-2.6-flash";
provider: "ant-ling";
};
"Ring-2.6-1T": Model<"openai-completions"> & {
id: "Ring-2.6-1T";
provider: "ant-ling";
};
};
export const ANT_LING_MODELS: ModelCatalog<typeof values, "ant-ling"> =
flattenModelCatalog("ant-ling", values);
+3 -59
View File
@@ -2,63 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/anthropic.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const ANTHROPIC_MODELS = values as {
"claude-fable-5": Model<"anthropic-messages"> & {
id: "claude-fable-5";
provider: "anthropic";
};
"claude-haiku-4-5": Model<"anthropic-messages"> & {
id: "claude-haiku-4-5";
provider: "anthropic";
};
"claude-haiku-4-5-20251001": Model<"anthropic-messages"> & {
id: "claude-haiku-4-5-20251001";
provider: "anthropic";
};
"claude-opus-4-1": Model<"anthropic-messages"> & {
id: "claude-opus-4-1";
provider: "anthropic";
};
"claude-opus-4-1-20250805": Model<"anthropic-messages"> & {
id: "claude-opus-4-1-20250805";
provider: "anthropic";
};
"claude-opus-4-5": Model<"anthropic-messages"> & {
id: "claude-opus-4-5";
provider: "anthropic";
};
"claude-opus-4-5-20251101": Model<"anthropic-messages"> & {
id: "claude-opus-4-5-20251101";
provider: "anthropic";
};
"claude-opus-4-6": Model<"anthropic-messages"> & {
id: "claude-opus-4-6";
provider: "anthropic";
};
"claude-opus-4-7": Model<"anthropic-messages"> & {
id: "claude-opus-4-7";
provider: "anthropic";
};
"claude-opus-4-8": Model<"anthropic-messages"> & {
id: "claude-opus-4-8";
provider: "anthropic";
};
"claude-sonnet-4-5": Model<"anthropic-messages"> & {
id: "claude-sonnet-4-5";
provider: "anthropic";
};
"claude-sonnet-4-5-20250929": Model<"anthropic-messages"> & {
id: "claude-sonnet-4-5-20250929";
provider: "anthropic";
};
"claude-sonnet-4-6": Model<"anthropic-messages"> & {
id: "claude-sonnet-4-6";
provider: "anthropic";
};
"claude-sonnet-5": Model<"anthropic-messages"> & {
id: "claude-sonnet-5";
provider: "anthropic";
};
};
export const ANTHROPIC_MODELS: ModelCatalog<typeof values, "anthropic"> =
flattenModelCatalog("anthropic", values);
@@ -2,191 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/azure-openai-responses.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const AZURE_OPENAI_RESPONSES_MODELS = values as {
"gpt-4": Model<"azure-openai-responses"> & {
id: "gpt-4";
provider: "azure-openai-responses";
};
"gpt-4-turbo": Model<"azure-openai-responses"> & {
id: "gpt-4-turbo";
provider: "azure-openai-responses";
};
"gpt-4.1": Model<"azure-openai-responses"> & {
id: "gpt-4.1";
provider: "azure-openai-responses";
};
"gpt-4.1-mini": Model<"azure-openai-responses"> & {
id: "gpt-4.1-mini";
provider: "azure-openai-responses";
};
"gpt-4.1-nano": Model<"azure-openai-responses"> & {
id: "gpt-4.1-nano";
provider: "azure-openai-responses";
};
"gpt-4o": Model<"azure-openai-responses"> & {
id: "gpt-4o";
provider: "azure-openai-responses";
};
"gpt-4o-2024-05-13": Model<"azure-openai-responses"> & {
id: "gpt-4o-2024-05-13";
provider: "azure-openai-responses";
};
"gpt-4o-2024-08-06": Model<"azure-openai-responses"> & {
id: "gpt-4o-2024-08-06";
provider: "azure-openai-responses";
};
"gpt-4o-2024-11-20": Model<"azure-openai-responses"> & {
id: "gpt-4o-2024-11-20";
provider: "azure-openai-responses";
};
"gpt-4o-mini": Model<"azure-openai-responses"> & {
id: "gpt-4o-mini";
provider: "azure-openai-responses";
};
"gpt-5": Model<"azure-openai-responses"> & {
id: "gpt-5";
provider: "azure-openai-responses";
};
"gpt-5-chat-latest": Model<"azure-openai-responses"> & {
id: "gpt-5-chat-latest";
provider: "azure-openai-responses";
};
"gpt-5-codex": Model<"azure-openai-responses"> & {
id: "gpt-5-codex";
provider: "azure-openai-responses";
};
"gpt-5-mini": Model<"azure-openai-responses"> & {
id: "gpt-5-mini";
provider: "azure-openai-responses";
};
"gpt-5-nano": Model<"azure-openai-responses"> & {
id: "gpt-5-nano";
provider: "azure-openai-responses";
};
"gpt-5-pro": Model<"azure-openai-responses"> & {
id: "gpt-5-pro";
provider: "azure-openai-responses";
};
"gpt-5.1": Model<"azure-openai-responses"> & {
id: "gpt-5.1";
provider: "azure-openai-responses";
};
"gpt-5.1-chat-latest": Model<"azure-openai-responses"> & {
id: "gpt-5.1-chat-latest";
provider: "azure-openai-responses";
};
"gpt-5.1-codex": Model<"azure-openai-responses"> & {
id: "gpt-5.1-codex";
provider: "azure-openai-responses";
};
"gpt-5.1-codex-max": Model<"azure-openai-responses"> & {
id: "gpt-5.1-codex-max";
provider: "azure-openai-responses";
};
"gpt-5.1-codex-mini": Model<"azure-openai-responses"> & {
id: "gpt-5.1-codex-mini";
provider: "azure-openai-responses";
};
"gpt-5.2": Model<"azure-openai-responses"> & {
id: "gpt-5.2";
provider: "azure-openai-responses";
};
"gpt-5.2-chat-latest": Model<"azure-openai-responses"> & {
id: "gpt-5.2-chat-latest";
provider: "azure-openai-responses";
};
"gpt-5.2-codex": Model<"azure-openai-responses"> & {
id: "gpt-5.2-codex";
provider: "azure-openai-responses";
};
"gpt-5.2-pro": Model<"azure-openai-responses"> & {
id: "gpt-5.2-pro";
provider: "azure-openai-responses";
};
"gpt-5.3-chat-latest": Model<"azure-openai-responses"> & {
id: "gpt-5.3-chat-latest";
provider: "azure-openai-responses";
};
"gpt-5.3-codex": Model<"azure-openai-responses"> & {
id: "gpt-5.3-codex";
provider: "azure-openai-responses";
};
"gpt-5.3-codex-spark": Model<"azure-openai-responses"> & {
id: "gpt-5.3-codex-spark";
provider: "azure-openai-responses";
};
"gpt-5.4": Model<"azure-openai-responses"> & {
id: "gpt-5.4";
provider: "azure-openai-responses";
};
"gpt-5.4-mini": Model<"azure-openai-responses"> & {
id: "gpt-5.4-mini";
provider: "azure-openai-responses";
};
"gpt-5.4-nano": Model<"azure-openai-responses"> & {
id: "gpt-5.4-nano";
provider: "azure-openai-responses";
};
"gpt-5.4-pro": Model<"azure-openai-responses"> & {
id: "gpt-5.4-pro";
provider: "azure-openai-responses";
};
"gpt-5.5": Model<"azure-openai-responses"> & {
id: "gpt-5.5";
provider: "azure-openai-responses";
};
"gpt-5.5-pro": Model<"azure-openai-responses"> & {
id: "gpt-5.5-pro";
provider: "azure-openai-responses";
};
"gpt-5.6-luna": Model<"azure-openai-responses"> & {
id: "gpt-5.6-luna";
provider: "azure-openai-responses";
};
"gpt-5.6-sol": Model<"azure-openai-responses"> & {
id: "gpt-5.6-sol";
provider: "azure-openai-responses";
};
"gpt-5.6-terra": Model<"azure-openai-responses"> & {
id: "gpt-5.6-terra";
provider: "azure-openai-responses";
};
"gpt-realtime-2.1": Model<"azure-openai-responses"> & {
id: "gpt-realtime-2.1";
provider: "azure-openai-responses";
};
"o1": Model<"azure-openai-responses"> & {
id: "o1";
provider: "azure-openai-responses";
};
"o1-pro": Model<"azure-openai-responses"> & {
id: "o1-pro";
provider: "azure-openai-responses";
};
"o3": Model<"azure-openai-responses"> & {
id: "o3";
provider: "azure-openai-responses";
};
"o3-deep-research": Model<"azure-openai-responses"> & {
id: "o3-deep-research";
provider: "azure-openai-responses";
};
"o3-mini": Model<"azure-openai-responses"> & {
id: "o3-mini";
provider: "azure-openai-responses";
};
"o3-pro": Model<"azure-openai-responses"> & {
id: "o3-pro";
provider: "azure-openai-responses";
};
"o4-mini": Model<"azure-openai-responses"> & {
id: "o4-mini";
provider: "azure-openai-responses";
};
"o4-mini-deep-research": Model<"azure-openai-responses"> & {
id: "o4-mini-deep-research";
provider: "azure-openai-responses";
};
};
export const AZURE_OPENAI_RESPONSES_MODELS: ModelCatalog<typeof values, "azure-openai-responses"> =
flattenModelCatalog("azure-openai-responses", values);
+3 -15
View File
@@ -2,19 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/cerebras.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const CEREBRAS_MODELS = values as {
"gemma-4-31b": Model<"openai-completions"> & {
id: "gemma-4-31b";
provider: "cerebras";
};
"gpt-oss-120b": Model<"openai-completions"> & {
id: "gpt-oss-120b";
provider: "cerebras";
};
"zai-glm-4.7": Model<"openai-completions"> & {
id: "zai-glm-4.7";
provider: "cerebras";
};
};
export const CEREBRAS_MODELS: ModelCatalog<typeof values, "cerebras"> =
flattenModelCatalog("cerebras", values);
@@ -2,175 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/cloudflare-ai-gateway.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const CLOUDFLARE_AI_GATEWAY_MODELS = values as {
"claude-3-5-haiku": Model<"anthropic-messages"> & {
id: "claude-3-5-haiku";
provider: "cloudflare-ai-gateway";
};
"claude-3-haiku": Model<"anthropic-messages"> & {
id: "claude-3-haiku";
provider: "cloudflare-ai-gateway";
};
"claude-3-opus": Model<"anthropic-messages"> & {
id: "claude-3-opus";
provider: "cloudflare-ai-gateway";
};
"claude-3-sonnet": Model<"anthropic-messages"> & {
id: "claude-3-sonnet";
provider: "cloudflare-ai-gateway";
};
"claude-3.5-haiku": Model<"anthropic-messages"> & {
id: "claude-3.5-haiku";
provider: "cloudflare-ai-gateway";
};
"claude-3.5-sonnet": Model<"anthropic-messages"> & {
id: "claude-3.5-sonnet";
provider: "cloudflare-ai-gateway";
};
"claude-fable-5": Model<"anthropic-messages"> & {
id: "claude-fable-5";
provider: "cloudflare-ai-gateway";
};
"claude-haiku-4-5": Model<"anthropic-messages"> & {
id: "claude-haiku-4-5";
provider: "cloudflare-ai-gateway";
};
"claude-opus-4": Model<"anthropic-messages"> & {
id: "claude-opus-4";
provider: "cloudflare-ai-gateway";
};
"claude-opus-4-1": Model<"anthropic-messages"> & {
id: "claude-opus-4-1";
provider: "cloudflare-ai-gateway";
};
"claude-opus-4-5": Model<"anthropic-messages"> & {
id: "claude-opus-4-5";
provider: "cloudflare-ai-gateway";
};
"claude-opus-4-6": Model<"anthropic-messages"> & {
id: "claude-opus-4-6";
provider: "cloudflare-ai-gateway";
};
"claude-opus-4-7": Model<"anthropic-messages"> & {
id: "claude-opus-4-7";
provider: "cloudflare-ai-gateway";
};
"claude-opus-4-8": Model<"anthropic-messages"> & {
id: "claude-opus-4-8";
provider: "cloudflare-ai-gateway";
};
"claude-sonnet-4": Model<"anthropic-messages"> & {
id: "claude-sonnet-4";
provider: "cloudflare-ai-gateway";
};
"claude-sonnet-4-5": Model<"anthropic-messages"> & {
id: "claude-sonnet-4-5";
provider: "cloudflare-ai-gateway";
};
"claude-sonnet-4-6": Model<"anthropic-messages"> & {
id: "claude-sonnet-4-6";
provider: "cloudflare-ai-gateway";
};
"claude-sonnet-5": Model<"anthropic-messages"> & {
id: "claude-sonnet-5";
provider: "cloudflare-ai-gateway";
};
"gpt-4": Model<"openai-responses"> & {
id: "gpt-4";
provider: "cloudflare-ai-gateway";
};
"gpt-4-turbo": Model<"openai-responses"> & {
id: "gpt-4-turbo";
provider: "cloudflare-ai-gateway";
};
"gpt-4o": Model<"openai-responses"> & {
id: "gpt-4o";
provider: "cloudflare-ai-gateway";
};
"gpt-4o-mini": Model<"openai-responses"> & {
id: "gpt-4o-mini";
provider: "cloudflare-ai-gateway";
};
"gpt-5.1": Model<"openai-responses"> & {
id: "gpt-5.1";
provider: "cloudflare-ai-gateway";
};
"gpt-5.1-codex": Model<"openai-responses"> & {
id: "gpt-5.1-codex";
provider: "cloudflare-ai-gateway";
};
"gpt-5.2": Model<"openai-responses"> & {
id: "gpt-5.2";
provider: "cloudflare-ai-gateway";
};
"gpt-5.2-codex": Model<"openai-responses"> & {
id: "gpt-5.2-codex";
provider: "cloudflare-ai-gateway";
};
"gpt-5.3-codex": Model<"openai-responses"> & {
id: "gpt-5.3-codex";
provider: "cloudflare-ai-gateway";
};
"gpt-5.4": Model<"openai-responses"> & {
id: "gpt-5.4";
provider: "cloudflare-ai-gateway";
};
"gpt-5.5": Model<"openai-responses"> & {
id: "gpt-5.5";
provider: "cloudflare-ai-gateway";
};
"gpt-5.6-luna": Model<"openai-responses"> & {
id: "gpt-5.6-luna";
provider: "cloudflare-ai-gateway";
};
"gpt-5.6-sol": Model<"openai-responses"> & {
id: "gpt-5.6-sol";
provider: "cloudflare-ai-gateway";
};
"gpt-5.6-terra": Model<"openai-responses"> & {
id: "gpt-5.6-terra";
provider: "cloudflare-ai-gateway";
};
"o1": Model<"openai-responses"> & {
id: "o1";
provider: "cloudflare-ai-gateway";
};
"o3": Model<"openai-responses"> & {
id: "o3";
provider: "cloudflare-ai-gateway";
};
"o3-mini": Model<"openai-responses"> & {
id: "o3-mini";
provider: "cloudflare-ai-gateway";
};
"o3-pro": Model<"openai-responses"> & {
id: "o3-pro";
provider: "cloudflare-ai-gateway";
};
"o4-mini": Model<"openai-responses"> & {
id: "o4-mini";
provider: "cloudflare-ai-gateway";
};
"workers-ai/@cf/moonshotai/kimi-k2.5": Model<"openai-completions"> & {
id: "workers-ai/@cf/moonshotai/kimi-k2.5";
provider: "cloudflare-ai-gateway";
};
"workers-ai/@cf/moonshotai/kimi-k2.6": Model<"openai-completions"> & {
id: "workers-ai/@cf/moonshotai/kimi-k2.6";
provider: "cloudflare-ai-gateway";
};
"workers-ai/@cf/nvidia/nemotron-3-120b-a12b": Model<"openai-completions"> & {
id: "workers-ai/@cf/nvidia/nemotron-3-120b-a12b";
provider: "cloudflare-ai-gateway";
};
"workers-ai/@cf/zai-org/glm-4.7-flash": Model<"openai-completions"> & {
id: "workers-ai/@cf/zai-org/glm-4.7-flash";
provider: "cloudflare-ai-gateway";
};
"workers-ai/@cf/zai-org/glm-5.2": Model<"openai-completions"> & {
id: "workers-ai/@cf/zai-org/glm-5.2";
provider: "cloudflare-ai-gateway";
};
};
export const CLOUDFLARE_AI_GATEWAY_MODELS: ModelCatalog<typeof values, "cloudflare-ai-gateway"> =
flattenModelCatalog("cloudflare-ai-gateway", values);
@@ -2,59 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/cloudflare-workers-ai.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const CLOUDFLARE_WORKERS_AI_MODELS = values as {
"@cf/google/gemma-4-26b-a4b-it": Model<"openai-completions"> & {
id: "@cf/google/gemma-4-26b-a4b-it";
provider: "cloudflare-workers-ai";
};
"@cf/ibm-granite/granite-4.0-h-micro": Model<"openai-completions"> & {
id: "@cf/ibm-granite/granite-4.0-h-micro";
provider: "cloudflare-workers-ai";
};
"@cf/meta/llama-3.3-70b-instruct-fp8-fast": Model<"openai-completions"> & {
id: "@cf/meta/llama-3.3-70b-instruct-fp8-fast";
provider: "cloudflare-workers-ai";
};
"@cf/meta/llama-4-scout-17b-16e-instruct": Model<"openai-completions"> & {
id: "@cf/meta/llama-4-scout-17b-16e-instruct";
provider: "cloudflare-workers-ai";
};
"@cf/mistralai/mistral-small-3.1-24b-instruct": Model<"openai-completions"> & {
id: "@cf/mistralai/mistral-small-3.1-24b-instruct";
provider: "cloudflare-workers-ai";
};
"@cf/moonshotai/kimi-k2.6": Model<"openai-completions"> & {
id: "@cf/moonshotai/kimi-k2.6";
provider: "cloudflare-workers-ai";
};
"@cf/moonshotai/kimi-k2.7-code": Model<"openai-completions"> & {
id: "@cf/moonshotai/kimi-k2.7-code";
provider: "cloudflare-workers-ai";
};
"@cf/nvidia/nemotron-3-120b-a12b": Model<"openai-completions"> & {
id: "@cf/nvidia/nemotron-3-120b-a12b";
provider: "cloudflare-workers-ai";
};
"@cf/openai/gpt-oss-120b": Model<"openai-completions"> & {
id: "@cf/openai/gpt-oss-120b";
provider: "cloudflare-workers-ai";
};
"@cf/openai/gpt-oss-20b": Model<"openai-completions"> & {
id: "@cf/openai/gpt-oss-20b";
provider: "cloudflare-workers-ai";
};
"@cf/qwen/qwen3-30b-a3b-fp8": Model<"openai-completions"> & {
id: "@cf/qwen/qwen3-30b-a3b-fp8";
provider: "cloudflare-workers-ai";
};
"@cf/zai-org/glm-4.7-flash": Model<"openai-completions"> & {
id: "@cf/zai-org/glm-4.7-flash";
provider: "cloudflare-workers-ai";
};
"@cf/zai-org/glm-5.2": Model<"openai-completions"> & {
id: "@cf/zai-org/glm-5.2";
provider: "cloudflare-workers-ai";
};
};
export const CLOUDFLARE_WORKERS_AI_MODELS: ModelCatalog<typeof values, "cloudflare-workers-ai"> =
flattenModelCatalog("cloudflare-workers-ai", values);
+3 -11
View File
@@ -2,15 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/deepseek.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const DEEPSEEK_MODELS = values as {
"deepseek-v4-flash": Model<"openai-completions"> & {
id: "deepseek-v4-flash";
provider: "deepseek";
};
"deepseek-v4-pro": Model<"openai-completions"> & {
id: "deepseek-v4-pro";
provider: "deepseek";
};
};
export const DEEPSEEK_MODELS: ModelCatalog<typeof values, "deepseek"> =
flattenModelCatalog("deepseek", values);
+3 -67
View File
@@ -2,71 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/fireworks.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const FIREWORKS_MODELS = values as {
"accounts/fireworks/models/deepseek-v4-flash": Model<"anthropic-messages"> & {
id: "accounts/fireworks/models/deepseek-v4-flash";
provider: "fireworks";
};
"accounts/fireworks/models/deepseek-v4-pro": Model<"anthropic-messages"> & {
id: "accounts/fireworks/models/deepseek-v4-pro";
provider: "fireworks";
};
"accounts/fireworks/models/glm-5p1": Model<"anthropic-messages"> & {
id: "accounts/fireworks/models/glm-5p1";
provider: "fireworks";
};
"accounts/fireworks/models/glm-5p2": Model<"openai-completions"> & {
id: "accounts/fireworks/models/glm-5p2";
provider: "fireworks";
};
"accounts/fireworks/models/gpt-oss-120b": Model<"anthropic-messages"> & {
id: "accounts/fireworks/models/gpt-oss-120b";
provider: "fireworks";
};
"accounts/fireworks/models/gpt-oss-20b": Model<"anthropic-messages"> & {
id: "accounts/fireworks/models/gpt-oss-20b";
provider: "fireworks";
};
"accounts/fireworks/models/kimi-k2p6": Model<"anthropic-messages"> & {
id: "accounts/fireworks/models/kimi-k2p6";
provider: "fireworks";
};
"accounts/fireworks/models/kimi-k2p7-code": Model<"anthropic-messages"> & {
id: "accounts/fireworks/models/kimi-k2p7-code";
provider: "fireworks";
};
"accounts/fireworks/models/minimax-m2p7": Model<"anthropic-messages"> & {
id: "accounts/fireworks/models/minimax-m2p7";
provider: "fireworks";
};
"accounts/fireworks/models/minimax-m3": Model<"anthropic-messages"> & {
id: "accounts/fireworks/models/minimax-m3";
provider: "fireworks";
};
"accounts/fireworks/models/qwen3p7-plus": Model<"anthropic-messages"> & {
id: "accounts/fireworks/models/qwen3p7-plus";
provider: "fireworks";
};
"accounts/fireworks/routers/glm-5p1-fast": Model<"anthropic-messages"> & {
id: "accounts/fireworks/routers/glm-5p1-fast";
provider: "fireworks";
};
"accounts/fireworks/routers/glm-5p2-fast": Model<"openai-completions"> & {
id: "accounts/fireworks/routers/glm-5p2-fast";
provider: "fireworks";
};
"accounts/fireworks/routers/kimi-k2p6-fast": Model<"anthropic-messages"> & {
id: "accounts/fireworks/routers/kimi-k2p6-fast";
provider: "fireworks";
};
"accounts/fireworks/routers/kimi-k2p6-turbo": Model<"anthropic-messages"> & {
id: "accounts/fireworks/routers/kimi-k2p6-turbo";
provider: "fireworks";
};
"accounts/fireworks/routers/kimi-k2p7-code-fast": Model<"anthropic-messages"> & {
id: "accounts/fireworks/routers/kimi-k2p7-code-fast";
provider: "fireworks";
};
};
export const FIREWORKS_MODELS: ModelCatalog<typeof values, "fireworks"> =
flattenModelCatalog("fireworks", values);
@@ -2,119 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/github-copilot.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const GITHUB_COPILOT_MODELS = values as {
"claude-fable-5": Model<"openai-completions"> & {
id: "claude-fable-5";
provider: "github-copilot";
};
"claude-haiku-4.5": Model<"anthropic-messages"> & {
id: "claude-haiku-4.5";
provider: "github-copilot";
};
"claude-opus-4.5": Model<"anthropic-messages"> & {
id: "claude-opus-4.5";
provider: "github-copilot";
};
"claude-opus-4.6": Model<"anthropic-messages"> & {
id: "claude-opus-4.6";
provider: "github-copilot";
};
"claude-opus-4.7": Model<"anthropic-messages"> & {
id: "claude-opus-4.7";
provider: "github-copilot";
};
"claude-opus-4.8": Model<"anthropic-messages"> & {
id: "claude-opus-4.8";
provider: "github-copilot";
};
"claude-sonnet-4": Model<"anthropic-messages"> & {
id: "claude-sonnet-4";
provider: "github-copilot";
};
"claude-sonnet-4.5": Model<"anthropic-messages"> & {
id: "claude-sonnet-4.5";
provider: "github-copilot";
};
"claude-sonnet-4.6": Model<"anthropic-messages"> & {
id: "claude-sonnet-4.6";
provider: "github-copilot";
};
"claude-sonnet-5": Model<"anthropic-messages"> & {
id: "claude-sonnet-5";
provider: "github-copilot";
};
"gemini-2.5-pro": Model<"openai-completions"> & {
id: "gemini-2.5-pro";
provider: "github-copilot";
};
"gemini-3-flash-preview": Model<"openai-completions"> & {
id: "gemini-3-flash-preview";
provider: "github-copilot";
};
"gemini-3.1-pro-preview": Model<"openai-completions"> & {
id: "gemini-3.1-pro-preview";
provider: "github-copilot";
};
"gemini-3.5-flash": Model<"openai-completions"> & {
id: "gemini-3.5-flash";
provider: "github-copilot";
};
"gpt-4.1": Model<"openai-completions"> & {
id: "gpt-4.1";
provider: "github-copilot";
};
"gpt-5-mini": Model<"openai-responses"> & {
id: "gpt-5-mini";
provider: "github-copilot";
};
"gpt-5.2": Model<"openai-responses"> & {
id: "gpt-5.2";
provider: "github-copilot";
};
"gpt-5.2-codex": Model<"openai-responses"> & {
id: "gpt-5.2-codex";
provider: "github-copilot";
};
"gpt-5.3-codex": Model<"openai-responses"> & {
id: "gpt-5.3-codex";
provider: "github-copilot";
};
"gpt-5.4": Model<"openai-responses"> & {
id: "gpt-5.4";
provider: "github-copilot";
};
"gpt-5.4-mini": Model<"openai-responses"> & {
id: "gpt-5.4-mini";
provider: "github-copilot";
};
"gpt-5.4-nano": Model<"openai-responses"> & {
id: "gpt-5.4-nano";
provider: "github-copilot";
};
"gpt-5.5": Model<"openai-responses"> & {
id: "gpt-5.5";
provider: "github-copilot";
};
"gpt-5.6-luna": Model<"openai-responses"> & {
id: "gpt-5.6-luna";
provider: "github-copilot";
};
"gpt-5.6-sol": Model<"openai-responses"> & {
id: "gpt-5.6-sol";
provider: "github-copilot";
};
"gpt-5.6-terra": Model<"openai-responses"> & {
id: "gpt-5.6-terra";
provider: "github-copilot";
};
"kimi-k2.7-code": Model<"openai-completions"> & {
id: "kimi-k2.7-code";
provider: "github-copilot";
};
"mai-code-1-flash-picker": Model<"openai-responses"> & {
id: "mai-code-1-flash-picker";
provider: "github-copilot";
};
};
export const GITHUB_COPILOT_MODELS: ModelCatalog<typeof values, "github-copilot"> =
flattenModelCatalog("github-copilot", values);
@@ -2,47 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/google-vertex.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const GOOGLE_VERTEX_MODELS = values as {
"gemini-2.5-flash": Model<"google-vertex"> & {
id: "gemini-2.5-flash";
provider: "google-vertex";
};
"gemini-2.5-flash-lite": Model<"google-vertex"> & {
id: "gemini-2.5-flash-lite";
provider: "google-vertex";
};
"gemini-2.5-pro": Model<"google-vertex"> & {
id: "gemini-2.5-pro";
provider: "google-vertex";
};
"gemini-3-flash-preview": Model<"google-vertex"> & {
id: "gemini-3-flash-preview";
provider: "google-vertex";
};
"gemini-3.1-flash-lite": Model<"google-vertex"> & {
id: "gemini-3.1-flash-lite";
provider: "google-vertex";
};
"gemini-3.1-pro-preview": Model<"google-vertex"> & {
id: "gemini-3.1-pro-preview";
provider: "google-vertex";
};
"gemini-3.1-pro-preview-customtools": Model<"google-vertex"> & {
id: "gemini-3.1-pro-preview-customtools";
provider: "google-vertex";
};
"gemini-3.5-flash": Model<"google-vertex"> & {
id: "gemini-3.5-flash";
provider: "google-vertex";
};
"gemini-flash-latest": Model<"google-vertex"> & {
id: "gemini-flash-latest";
provider: "google-vertex";
};
"gemini-flash-lite-latest": Model<"google-vertex"> & {
id: "gemini-flash-lite-latest";
provider: "google-vertex";
};
};
export const GOOGLE_VERTEX_MODELS: ModelCatalog<typeof values, "google-vertex"> =
flattenModelCatalog("google-vertex", values);
+3 -75
View File
@@ -2,79 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/google.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const GOOGLE_MODELS = values as {
"gemini-2.0-flash": Model<"google-generative-ai"> & {
id: "gemini-2.0-flash";
provider: "google";
};
"gemini-2.0-flash-lite": Model<"google-generative-ai"> & {
id: "gemini-2.0-flash-lite";
provider: "google";
};
"gemini-2.5-flash": Model<"google-generative-ai"> & {
id: "gemini-2.5-flash";
provider: "google";
};
"gemini-2.5-flash-lite": Model<"google-generative-ai"> & {
id: "gemini-2.5-flash-lite";
provider: "google";
};
"gemini-2.5-pro": Model<"google-generative-ai"> & {
id: "gemini-2.5-pro";
provider: "google";
};
"gemini-3-flash-preview": Model<"google-generative-ai"> & {
id: "gemini-3-flash-preview";
provider: "google";
};
"gemini-3-pro-preview": Model<"google-generative-ai"> & {
id: "gemini-3-pro-preview";
provider: "google";
};
"gemini-3.1-flash-lite": Model<"google-generative-ai"> & {
id: "gemini-3.1-flash-lite";
provider: "google";
};
"gemini-3.1-flash-lite-preview": Model<"google-generative-ai"> & {
id: "gemini-3.1-flash-lite-preview";
provider: "google";
};
"gemini-3.1-pro-preview": Model<"google-generative-ai"> & {
id: "gemini-3.1-pro-preview";
provider: "google";
};
"gemini-3.1-pro-preview-customtools": Model<"google-generative-ai"> & {
id: "gemini-3.1-pro-preview-customtools";
provider: "google";
};
"gemini-3.5-flash": Model<"google-generative-ai"> & {
id: "gemini-3.5-flash";
provider: "google";
};
"gemini-3.5-flash-lite": Model<"google-generative-ai"> & {
id: "gemini-3.5-flash-lite";
provider: "google";
};
"gemini-3.6-flash": Model<"google-generative-ai"> & {
id: "gemini-3.6-flash";
provider: "google";
};
"gemini-flash-latest": Model<"google-generative-ai"> & {
id: "gemini-flash-latest";
provider: "google";
};
"gemini-flash-lite-latest": Model<"google-generative-ai"> & {
id: "gemini-flash-lite-latest";
provider: "google";
};
"gemma-4-26b-a4b-it": Model<"google-generative-ai"> & {
id: "gemma-4-26b-a4b-it";
provider: "google";
};
"gemma-4-31b-it": Model<"google-generative-ai"> & {
id: "gemma-4-31b-it";
provider: "google";
};
};
export const GOOGLE_MODELS: ModelCatalog<typeof values, "google"> =
flattenModelCatalog("google", values);
+3 -31
View File
@@ -2,35 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/groq.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const GROQ_MODELS = values as {
"llama-3.1-8b-instant": Model<"openai-completions"> & {
id: "llama-3.1-8b-instant";
provider: "groq";
};
"llama-3.3-70b-versatile": Model<"openai-completions"> & {
id: "llama-3.3-70b-versatile";
provider: "groq";
};
"meta-llama/llama-4-scout-17b-16e-instruct": Model<"openai-completions"> & {
id: "meta-llama/llama-4-scout-17b-16e-instruct";
provider: "groq";
};
"openai/gpt-oss-120b": Model<"openai-completions"> & {
id: "openai/gpt-oss-120b";
provider: "groq";
};
"openai/gpt-oss-20b": Model<"openai-completions"> & {
id: "openai/gpt-oss-20b";
provider: "groq";
};
"openai/gpt-oss-safeguard-20b": Model<"openai-completions"> & {
id: "openai/gpt-oss-safeguard-20b";
provider: "groq";
};
"qwen/qwen3-32b": Model<"openai-completions"> & {
id: "qwen/qwen3-32b";
provider: "groq";
};
};
export const GROQ_MODELS: ModelCatalog<typeof values, "groq"> =
flattenModelCatalog("groq", values);
+3 -199
View File
@@ -2,203 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/huggingface.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const HUGGINGFACE_MODELS = values as {
"MiniMaxAI/MiniMax-M2": Model<"openai-completions"> & {
id: "MiniMaxAI/MiniMax-M2";
provider: "huggingface";
};
"MiniMaxAI/MiniMax-M2.1": Model<"openai-completions"> & {
id: "MiniMaxAI/MiniMax-M2.1";
provider: "huggingface";
};
"MiniMaxAI/MiniMax-M2.5": Model<"openai-completions"> & {
id: "MiniMaxAI/MiniMax-M2.5";
provider: "huggingface";
};
"MiniMaxAI/MiniMax-M2.7": Model<"openai-completions"> & {
id: "MiniMaxAI/MiniMax-M2.7";
provider: "huggingface";
};
"MiniMaxAI/MiniMax-M3": Model<"openai-completions"> & {
id: "MiniMaxAI/MiniMax-M3";
provider: "huggingface";
};
"Qwen/Qwen3-235B-A22B": Model<"openai-completions"> & {
id: "Qwen/Qwen3-235B-A22B";
provider: "huggingface";
};
"Qwen/Qwen3-235B-A22B-Thinking-2507": Model<"openai-completions"> & {
id: "Qwen/Qwen3-235B-A22B-Thinking-2507";
provider: "huggingface";
};
"Qwen/Qwen3-32B": Model<"openai-completions"> & {
id: "Qwen/Qwen3-32B";
provider: "huggingface";
};
"Qwen/Qwen3-Coder-30B-A3B-Instruct": Model<"openai-completions"> & {
id: "Qwen/Qwen3-Coder-30B-A3B-Instruct";
provider: "huggingface";
};
"Qwen/Qwen3-Coder-480B-A35B-Instruct": Model<"openai-completions"> & {
id: "Qwen/Qwen3-Coder-480B-A35B-Instruct";
provider: "huggingface";
};
"Qwen/Qwen3-Coder-Next": Model<"openai-completions"> & {
id: "Qwen/Qwen3-Coder-Next";
provider: "huggingface";
};
"Qwen/Qwen3-Next-80B-A3B-Instruct": Model<"openai-completions"> & {
id: "Qwen/Qwen3-Next-80B-A3B-Instruct";
provider: "huggingface";
};
"Qwen/Qwen3-Next-80B-A3B-Thinking": Model<"openai-completions"> & {
id: "Qwen/Qwen3-Next-80B-A3B-Thinking";
provider: "huggingface";
};
"Qwen/Qwen3.5-122B-A10B": Model<"openai-completions"> & {
id: "Qwen/Qwen3.5-122B-A10B";
provider: "huggingface";
};
"Qwen/Qwen3.5-27B": Model<"openai-completions"> & {
id: "Qwen/Qwen3.5-27B";
provider: "huggingface";
};
"Qwen/Qwen3.5-35B-A3B": Model<"openai-completions"> & {
id: "Qwen/Qwen3.5-35B-A3B";
provider: "huggingface";
};
"Qwen/Qwen3.5-397B-A17B": Model<"openai-completions"> & {
id: "Qwen/Qwen3.5-397B-A17B";
provider: "huggingface";
};
"Qwen/Qwen3.5-9B": Model<"openai-completions"> & {
id: "Qwen/Qwen3.5-9B";
provider: "huggingface";
};
"Qwen/Qwen3.6-27B": Model<"openai-completions"> & {
id: "Qwen/Qwen3.6-27B";
provider: "huggingface";
};
"Qwen/Qwen3.6-35B-A3B": Model<"openai-completions"> & {
id: "Qwen/Qwen3.6-35B-A3B";
provider: "huggingface";
};
"XiaomiMiMo/MiMo-V2-Flash": Model<"openai-completions"> & {
id: "XiaomiMiMo/MiMo-V2-Flash";
provider: "huggingface";
};
"XiaomiMiMo/MiMo-V2.5-Pro": Model<"openai-completions"> & {
id: "XiaomiMiMo/MiMo-V2.5-Pro";
provider: "huggingface";
};
"deepseek-ai/DeepSeek-R1": Model<"openai-completions"> & {
id: "deepseek-ai/DeepSeek-R1";
provider: "huggingface";
};
"deepseek-ai/DeepSeek-R1-0528": Model<"openai-completions"> & {
id: "deepseek-ai/DeepSeek-R1-0528";
provider: "huggingface";
};
"deepseek-ai/DeepSeek-V3.2": Model<"openai-completions"> & {
id: "deepseek-ai/DeepSeek-V3.2";
provider: "huggingface";
};
"deepseek-ai/DeepSeek-V4-Flash": Model<"openai-completions"> & {
id: "deepseek-ai/DeepSeek-V4-Flash";
provider: "huggingface";
};
"deepseek-ai/DeepSeek-V4-Pro": Model<"openai-completions"> & {
id: "deepseek-ai/DeepSeek-V4-Pro";
provider: "huggingface";
};
"google/gemma-4-26B-A4B-it": Model<"openai-completions"> & {
id: "google/gemma-4-26B-A4B-it";
provider: "huggingface";
};
"google/gemma-4-31B-it": Model<"openai-completions"> & {
id: "google/gemma-4-31B-it";
provider: "huggingface";
};
"meta-llama/Llama-3.3-70B-Instruct": Model<"openai-completions"> & {
id: "meta-llama/Llama-3.3-70B-Instruct";
provider: "huggingface";
};
"moonshotai/Kimi-K2-Instruct": Model<"openai-completions"> & {
id: "moonshotai/Kimi-K2-Instruct";
provider: "huggingface";
};
"moonshotai/Kimi-K2-Instruct-0905": Model<"openai-completions"> & {
id: "moonshotai/Kimi-K2-Instruct-0905";
provider: "huggingface";
};
"moonshotai/Kimi-K2-Thinking": Model<"openai-completions"> & {
id: "moonshotai/Kimi-K2-Thinking";
provider: "huggingface";
};
"moonshotai/Kimi-K2.5": Model<"openai-completions"> & {
id: "moonshotai/Kimi-K2.5";
provider: "huggingface";
};
"moonshotai/Kimi-K2.6": Model<"openai-completions"> & {
id: "moonshotai/Kimi-K2.6";
provider: "huggingface";
};
"moonshotai/Kimi-K2.7-Code": Model<"openai-completions"> & {
id: "moonshotai/Kimi-K2.7-Code";
provider: "huggingface";
};
"openai/gpt-oss-120b": Model<"openai-completions"> & {
id: "openai/gpt-oss-120b";
provider: "huggingface";
};
"openai/gpt-oss-20b": Model<"openai-completions"> & {
id: "openai/gpt-oss-20b";
provider: "huggingface";
};
"stepfun-ai/Step-3.5-Flash": Model<"openai-completions"> & {
id: "stepfun-ai/Step-3.5-Flash";
provider: "huggingface";
};
"stepfun-ai/Step-3.7-Flash": Model<"openai-completions"> & {
id: "stepfun-ai/Step-3.7-Flash";
provider: "huggingface";
};
"zai-org/GLM-4.5": Model<"openai-completions"> & {
id: "zai-org/GLM-4.5";
provider: "huggingface";
};
"zai-org/GLM-4.5-Air": Model<"openai-completions"> & {
id: "zai-org/GLM-4.5-Air";
provider: "huggingface";
};
"zai-org/GLM-4.5V": Model<"openai-completions"> & {
id: "zai-org/GLM-4.5V";
provider: "huggingface";
};
"zai-org/GLM-4.6": Model<"openai-completions"> & {
id: "zai-org/GLM-4.6";
provider: "huggingface";
};
"zai-org/GLM-4.7": Model<"openai-completions"> & {
id: "zai-org/GLM-4.7";
provider: "huggingface";
};
"zai-org/GLM-4.7-Flash": Model<"openai-completions"> & {
id: "zai-org/GLM-4.7-Flash";
provider: "huggingface";
};
"zai-org/GLM-5": Model<"openai-completions"> & {
id: "zai-org/GLM-5";
provider: "huggingface";
};
"zai-org/GLM-5.1": Model<"openai-completions"> & {
id: "zai-org/GLM-5.1";
provider: "huggingface";
};
"zai-org/GLM-5.2": Model<"openai-completions"> & {
id: "zai-org/GLM-5.2";
provider: "huggingface";
};
};
export const HUGGINGFACE_MODELS: ModelCatalog<typeof values, "huggingface"> =
flattenModelCatalog("huggingface", values);
@@ -2,19 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/kimi-coding.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const KIMI_CODING_MODELS = values as {
"k3": Model<"anthropic-messages"> & {
id: "k3";
provider: "kimi-coding";
};
"kimi-for-coding": Model<"anthropic-messages"> & {
id: "kimi-for-coding";
provider: "kimi-coding";
};
"kimi-for-coding-highspeed": Model<"anthropic-messages"> & {
id: "kimi-for-coding-highspeed";
provider: "kimi-coding";
};
};
export const KIMI_CODING_MODELS: ModelCatalog<typeof values, "kimi-coding"> =
flattenModelCatalog("kimi-coding", values);
+3 -15
View File
@@ -2,19 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/minimax-cn.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const MINIMAX_CN_MODELS = values as {
"MiniMax-M2.7": Model<"anthropic-messages"> & {
id: "MiniMax-M2.7";
provider: "minimax-cn";
};
"MiniMax-M2.7-highspeed": Model<"anthropic-messages"> & {
id: "MiniMax-M2.7-highspeed";
provider: "minimax-cn";
};
"MiniMax-M3": Model<"anthropic-messages"> & {
id: "MiniMax-M3";
provider: "minimax-cn";
};
};
export const MINIMAX_CN_MODELS: ModelCatalog<typeof values, "minimax-cn"> =
flattenModelCatalog("minimax-cn", values);
+3 -15
View File
@@ -2,19 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/minimax.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const MINIMAX_MODELS = values as {
"MiniMax-M2.7": Model<"anthropic-messages"> & {
id: "MiniMax-M2.7";
provider: "minimax";
};
"MiniMax-M2.7-highspeed": Model<"anthropic-messages"> & {
id: "MiniMax-M2.7-highspeed";
provider: "minimax";
};
"MiniMax-M3": Model<"anthropic-messages"> & {
id: "MiniMax-M3";
provider: "minimax";
};
};
export const MINIMAX_MODELS: ModelCatalog<typeof values, "minimax"> =
flattenModelCatalog("minimax", values);
+3 -123
View File
@@ -2,127 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/mistral.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const MISTRAL_MODELS = values as {
"codestral-latest": Model<"mistral-conversations"> & {
id: "codestral-latest";
provider: "mistral";
};
"devstral-2512": Model<"mistral-conversations"> & {
id: "devstral-2512";
provider: "mistral";
};
"devstral-latest": Model<"mistral-conversations"> & {
id: "devstral-latest";
provider: "mistral";
};
"devstral-medium-2507": Model<"mistral-conversations"> & {
id: "devstral-medium-2507";
provider: "mistral";
};
"devstral-medium-latest": Model<"mistral-conversations"> & {
id: "devstral-medium-latest";
provider: "mistral";
};
"devstral-small-2505": Model<"mistral-conversations"> & {
id: "devstral-small-2505";
provider: "mistral";
};
"devstral-small-2507": Model<"mistral-conversations"> & {
id: "devstral-small-2507";
provider: "mistral";
};
"labs-devstral-small-2512": Model<"mistral-conversations"> & {
id: "labs-devstral-small-2512";
provider: "mistral";
};
"magistral-medium-latest": Model<"mistral-conversations"> & {
id: "magistral-medium-latest";
provider: "mistral";
};
"magistral-small": Model<"mistral-conversations"> & {
id: "magistral-small";
provider: "mistral";
};
"ministral-3b-latest": Model<"mistral-conversations"> & {
id: "ministral-3b-latest";
provider: "mistral";
};
"ministral-8b-latest": Model<"mistral-conversations"> & {
id: "ministral-8b-latest";
provider: "mistral";
};
"mistral-large-2411": Model<"mistral-conversations"> & {
id: "mistral-large-2411";
provider: "mistral";
};
"mistral-large-2512": Model<"mistral-conversations"> & {
id: "mistral-large-2512";
provider: "mistral";
};
"mistral-large-latest": Model<"mistral-conversations"> & {
id: "mistral-large-latest";
provider: "mistral";
};
"mistral-medium-2505": Model<"mistral-conversations"> & {
id: "mistral-medium-2505";
provider: "mistral";
};
"mistral-medium-2508": Model<"mistral-conversations"> & {
id: "mistral-medium-2508";
provider: "mistral";
};
"mistral-medium-2604": Model<"mistral-conversations"> & {
id: "mistral-medium-2604";
provider: "mistral";
};
"mistral-medium-3.5": Model<"mistral-conversations"> & {
id: "mistral-medium-3.5";
provider: "mistral";
};
"mistral-medium-latest": Model<"mistral-conversations"> & {
id: "mistral-medium-latest";
provider: "mistral";
};
"mistral-nemo": Model<"mistral-conversations"> & {
id: "mistral-nemo";
provider: "mistral";
};
"mistral-small-2506": Model<"mistral-conversations"> & {
id: "mistral-small-2506";
provider: "mistral";
};
"mistral-small-2603": Model<"mistral-conversations"> & {
id: "mistral-small-2603";
provider: "mistral";
};
"mistral-small-latest": Model<"mistral-conversations"> & {
id: "mistral-small-latest";
provider: "mistral";
};
"open-mistral-7b": Model<"mistral-conversations"> & {
id: "open-mistral-7b";
provider: "mistral";
};
"open-mistral-nemo": Model<"mistral-conversations"> & {
id: "open-mistral-nemo";
provider: "mistral";
};
"open-mixtral-8x22b": Model<"mistral-conversations"> & {
id: "open-mixtral-8x22b";
provider: "mistral";
};
"open-mixtral-8x7b": Model<"mistral-conversations"> & {
id: "open-mixtral-8x7b";
provider: "mistral";
};
"pixtral-12b": Model<"mistral-conversations"> & {
id: "pixtral-12b";
provider: "mistral";
};
"pixtral-large-latest": Model<"mistral-conversations"> & {
id: "pixtral-large-latest";
provider: "mistral";
};
};
export const MISTRAL_MODELS: ModelCatalog<typeof values, "mistral"> =
flattenModelCatalog("mistral", values);
@@ -2,47 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/moonshotai-cn.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const MOONSHOTAI_CN_MODELS = values as {
"kimi-k2-0711-preview": Model<"openai-completions"> & {
id: "kimi-k2-0711-preview";
provider: "moonshotai-cn";
};
"kimi-k2-0905-preview": Model<"openai-completions"> & {
id: "kimi-k2-0905-preview";
provider: "moonshotai-cn";
};
"kimi-k2-thinking": Model<"openai-completions"> & {
id: "kimi-k2-thinking";
provider: "moonshotai-cn";
};
"kimi-k2-thinking-turbo": Model<"openai-completions"> & {
id: "kimi-k2-thinking-turbo";
provider: "moonshotai-cn";
};
"kimi-k2-turbo-preview": Model<"openai-completions"> & {
id: "kimi-k2-turbo-preview";
provider: "moonshotai-cn";
};
"kimi-k2.5": Model<"openai-completions"> & {
id: "kimi-k2.5";
provider: "moonshotai-cn";
};
"kimi-k2.6": Model<"openai-completions"> & {
id: "kimi-k2.6";
provider: "moonshotai-cn";
};
"kimi-k2.7-code": Model<"openai-completions"> & {
id: "kimi-k2.7-code";
provider: "moonshotai-cn";
};
"kimi-k2.7-code-highspeed": Model<"openai-completions"> & {
id: "kimi-k2.7-code-highspeed";
provider: "moonshotai-cn";
};
"kimi-k3": Model<"openai-completions"> & {
id: "kimi-k3";
provider: "moonshotai-cn";
};
};
export const MOONSHOTAI_CN_MODELS: ModelCatalog<typeof values, "moonshotai-cn"> =
flattenModelCatalog("moonshotai-cn", values);
+3 -43
View File
@@ -2,47 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/moonshotai.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const MOONSHOTAI_MODELS = values as {
"kimi-k2-0711-preview": Model<"openai-completions"> & {
id: "kimi-k2-0711-preview";
provider: "moonshotai";
};
"kimi-k2-0905-preview": Model<"openai-completions"> & {
id: "kimi-k2-0905-preview";
provider: "moonshotai";
};
"kimi-k2-thinking": Model<"openai-completions"> & {
id: "kimi-k2-thinking";
provider: "moonshotai";
};
"kimi-k2-thinking-turbo": Model<"openai-completions"> & {
id: "kimi-k2-thinking-turbo";
provider: "moonshotai";
};
"kimi-k2-turbo-preview": Model<"openai-completions"> & {
id: "kimi-k2-turbo-preview";
provider: "moonshotai";
};
"kimi-k2.5": Model<"openai-completions"> & {
id: "kimi-k2.5";
provider: "moonshotai";
};
"kimi-k2.6": Model<"openai-completions"> & {
id: "kimi-k2.6";
provider: "moonshotai";
};
"kimi-k2.7-code": Model<"openai-completions"> & {
id: "kimi-k2.7-code";
provider: "moonshotai";
};
"kimi-k2.7-code-highspeed": Model<"openai-completions"> & {
id: "kimi-k2.7-code-highspeed";
provider: "moonshotai";
};
"kimi-k3": Model<"openai-completions"> & {
id: "kimi-k3";
provider: "moonshotai";
};
};
export const MOONSHOTAI_MODELS: ModelCatalog<typeof values, "moonshotai"> =
flattenModelCatalog("moonshotai", values);
+3 -79
View File
@@ -2,83 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/nvidia.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const NVIDIA_MODELS = values as {
"meta/llama-3.1-70b-instruct": Model<"openai-completions"> & {
id: "meta/llama-3.1-70b-instruct";
provider: "nvidia";
};
"meta/llama-3.1-8b-instruct": Model<"openai-completions"> & {
id: "meta/llama-3.1-8b-instruct";
provider: "nvidia";
};
"meta/llama-3.2-11b-vision-instruct": Model<"openai-completions"> & {
id: "meta/llama-3.2-11b-vision-instruct";
provider: "nvidia";
};
"meta/llama-3.2-90b-vision-instruct": Model<"openai-completions"> & {
id: "meta/llama-3.2-90b-vision-instruct";
provider: "nvidia";
};
"meta/llama-3.3-70b-instruct": Model<"openai-completions"> & {
id: "meta/llama-3.3-70b-instruct";
provider: "nvidia";
};
"minimaxai/minimax-m3": Model<"openai-completions"> & {
id: "minimaxai/minimax-m3";
provider: "nvidia";
};
"mistralai/mistral-large-3-675b-instruct-2512": Model<"openai-completions"> & {
id: "mistralai/mistral-large-3-675b-instruct-2512";
provider: "nvidia";
};
"mistralai/mistral-small-4-119b-2603": Model<"openai-completions"> & {
id: "mistralai/mistral-small-4-119b-2603";
provider: "nvidia";
};
"moonshotai/kimi-k2.6": Model<"openai-completions"> & {
id: "moonshotai/kimi-k2.6";
provider: "nvidia";
};
"nvidia/nemotron-3-nano-30b-a3b": Model<"openai-completions"> & {
id: "nvidia/nemotron-3-nano-30b-a3b";
provider: "nvidia";
};
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning": Model<"openai-completions"> & {
id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning";
provider: "nvidia";
};
"nvidia/nemotron-3-super-120b-a12b": Model<"openai-completions"> & {
id: "nvidia/nemotron-3-super-120b-a12b";
provider: "nvidia";
};
"nvidia/nemotron-3-ultra-550b-a55b": Model<"openai-completions"> & {
id: "nvidia/nemotron-3-ultra-550b-a55b";
provider: "nvidia";
};
"nvidia/nvidia-nemotron-nano-9b-v2": Model<"openai-completions"> & {
id: "nvidia/nvidia-nemotron-nano-9b-v2";
provider: "nvidia";
};
"openai/gpt-oss-120b": Model<"openai-completions"> & {
id: "openai/gpt-oss-120b";
provider: "nvidia";
};
"openai/gpt-oss-20b": Model<"openai-completions"> & {
id: "openai/gpt-oss-20b";
provider: "nvidia";
};
"stepfun-ai/step-3.5-flash": Model<"openai-completions"> & {
id: "stepfun-ai/step-3.5-flash";
provider: "nvidia";
};
"stepfun-ai/step-3.7-flash": Model<"openai-completions"> & {
id: "stepfun-ai/step-3.7-flash";
provider: "nvidia";
};
"z-ai/glm-5.2": Model<"openai-completions"> & {
id: "z-ai/glm-5.2";
provider: "nvidia";
};
};
export const NVIDIA_MODELS: ModelCatalog<typeof values, "nvidia"> =
flattenModelCatalog("nvidia", values);
@@ -2,35 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/openai-codex.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const OPENAI_CODEX_MODELS = values as {
"gpt-5.3-codex-spark": Model<"openai-codex-responses"> & {
id: "gpt-5.3-codex-spark";
provider: "openai-codex";
};
"gpt-5.4": Model<"openai-codex-responses"> & {
id: "gpt-5.4";
provider: "openai-codex";
};
"gpt-5.4-mini": Model<"openai-codex-responses"> & {
id: "gpt-5.4-mini";
provider: "openai-codex";
};
"gpt-5.5": Model<"openai-codex-responses"> & {
id: "gpt-5.5";
provider: "openai-codex";
};
"gpt-5.6-luna": Model<"openai-codex-responses"> & {
id: "gpt-5.6-luna";
provider: "openai-codex";
};
"gpt-5.6-sol": Model<"openai-codex-responses"> & {
id: "gpt-5.6-sol";
provider: "openai-codex";
};
"gpt-5.6-terra": Model<"openai-codex-responses"> & {
id: "gpt-5.6-terra";
provider: "openai-codex";
};
};
export const OPENAI_CODEX_MODELS: ModelCatalog<typeof values, "openai-codex"> =
flattenModelCatalog("openai-codex", values);
+3 -187
View File
@@ -2,191 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/openai.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const OPENAI_MODELS = values as {
"gpt-4": Model<"openai-responses"> & {
id: "gpt-4";
provider: "openai";
};
"gpt-4-turbo": Model<"openai-responses"> & {
id: "gpt-4-turbo";
provider: "openai";
};
"gpt-4.1": Model<"openai-responses"> & {
id: "gpt-4.1";
provider: "openai";
};
"gpt-4.1-mini": Model<"openai-responses"> & {
id: "gpt-4.1-mini";
provider: "openai";
};
"gpt-4.1-nano": Model<"openai-responses"> & {
id: "gpt-4.1-nano";
provider: "openai";
};
"gpt-4o": Model<"openai-responses"> & {
id: "gpt-4o";
provider: "openai";
};
"gpt-4o-2024-05-13": Model<"openai-responses"> & {
id: "gpt-4o-2024-05-13";
provider: "openai";
};
"gpt-4o-2024-08-06": Model<"openai-responses"> & {
id: "gpt-4o-2024-08-06";
provider: "openai";
};
"gpt-4o-2024-11-20": Model<"openai-responses"> & {
id: "gpt-4o-2024-11-20";
provider: "openai";
};
"gpt-4o-mini": Model<"openai-responses"> & {
id: "gpt-4o-mini";
provider: "openai";
};
"gpt-5": Model<"openai-responses"> & {
id: "gpt-5";
provider: "openai";
};
"gpt-5-chat-latest": Model<"openai-responses"> & {
id: "gpt-5-chat-latest";
provider: "openai";
};
"gpt-5-codex": Model<"openai-responses"> & {
id: "gpt-5-codex";
provider: "openai";
};
"gpt-5-mini": Model<"openai-responses"> & {
id: "gpt-5-mini";
provider: "openai";
};
"gpt-5-nano": Model<"openai-responses"> & {
id: "gpt-5-nano";
provider: "openai";
};
"gpt-5-pro": Model<"openai-responses"> & {
id: "gpt-5-pro";
provider: "openai";
};
"gpt-5.1": Model<"openai-responses"> & {
id: "gpt-5.1";
provider: "openai";
};
"gpt-5.1-chat-latest": Model<"openai-responses"> & {
id: "gpt-5.1-chat-latest";
provider: "openai";
};
"gpt-5.1-codex": Model<"openai-responses"> & {
id: "gpt-5.1-codex";
provider: "openai";
};
"gpt-5.1-codex-max": Model<"openai-responses"> & {
id: "gpt-5.1-codex-max";
provider: "openai";
};
"gpt-5.1-codex-mini": Model<"openai-responses"> & {
id: "gpt-5.1-codex-mini";
provider: "openai";
};
"gpt-5.2": Model<"openai-responses"> & {
id: "gpt-5.2";
provider: "openai";
};
"gpt-5.2-chat-latest": Model<"openai-responses"> & {
id: "gpt-5.2-chat-latest";
provider: "openai";
};
"gpt-5.2-codex": Model<"openai-responses"> & {
id: "gpt-5.2-codex";
provider: "openai";
};
"gpt-5.2-pro": Model<"openai-responses"> & {
id: "gpt-5.2-pro";
provider: "openai";
};
"gpt-5.3-chat-latest": Model<"openai-responses"> & {
id: "gpt-5.3-chat-latest";
provider: "openai";
};
"gpt-5.3-codex": Model<"openai-responses"> & {
id: "gpt-5.3-codex";
provider: "openai";
};
"gpt-5.3-codex-spark": Model<"openai-responses"> & {
id: "gpt-5.3-codex-spark";
provider: "openai";
};
"gpt-5.4": Model<"openai-responses"> & {
id: "gpt-5.4";
provider: "openai";
};
"gpt-5.4-mini": Model<"openai-responses"> & {
id: "gpt-5.4-mini";
provider: "openai";
};
"gpt-5.4-nano": Model<"openai-responses"> & {
id: "gpt-5.4-nano";
provider: "openai";
};
"gpt-5.4-pro": Model<"openai-responses"> & {
id: "gpt-5.4-pro";
provider: "openai";
};
"gpt-5.5": Model<"openai-responses"> & {
id: "gpt-5.5";
provider: "openai";
};
"gpt-5.5-pro": Model<"openai-responses"> & {
id: "gpt-5.5-pro";
provider: "openai";
};
"gpt-5.6-luna": Model<"openai-responses"> & {
id: "gpt-5.6-luna";
provider: "openai";
};
"gpt-5.6-sol": Model<"openai-responses"> & {
id: "gpt-5.6-sol";
provider: "openai";
};
"gpt-5.6-terra": Model<"openai-responses"> & {
id: "gpt-5.6-terra";
provider: "openai";
};
"gpt-realtime-2.1": Model<"openai-responses"> & {
id: "gpt-realtime-2.1";
provider: "openai";
};
"o1": Model<"openai-responses"> & {
id: "o1";
provider: "openai";
};
"o1-pro": Model<"openai-responses"> & {
id: "o1-pro";
provider: "openai";
};
"o3": Model<"openai-responses"> & {
id: "o3";
provider: "openai";
};
"o3-deep-research": Model<"openai-responses"> & {
id: "o3-deep-research";
provider: "openai";
};
"o3-mini": Model<"openai-responses"> & {
id: "o3-mini";
provider: "openai";
};
"o3-pro": Model<"openai-responses"> & {
id: "o3-pro";
provider: "openai";
};
"o4-mini": Model<"openai-responses"> & {
id: "o4-mini";
provider: "openai";
};
"o4-mini-deep-research": Model<"openai-responses"> & {
id: "o4-mini-deep-research";
provider: "openai";
};
};
export const OPENAI_MODELS: ModelCatalog<typeof values, "openai"> =
flattenModelCatalog("openai", values);
@@ -2,67 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/opencode-go.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const OPENCODE_GO_MODELS = values as {
"deepseek-v4-flash": Model<"openai-completions"> & {
id: "deepseek-v4-flash";
provider: "opencode-go";
};
"deepseek-v4-pro": Model<"openai-completions"> & {
id: "deepseek-v4-pro";
provider: "opencode-go";
};
"glm-5.1": Model<"openai-completions"> & {
id: "glm-5.1";
provider: "opencode-go";
};
"glm-5.2": Model<"openai-completions"> & {
id: "glm-5.2";
provider: "opencode-go";
};
"grok-4.5": Model<"openai-responses"> & {
id: "grok-4.5";
provider: "opencode-go";
};
"kimi-k2.6": Model<"openai-completions"> & {
id: "kimi-k2.6";
provider: "opencode-go";
};
"kimi-k2.7-code": Model<"openai-completions"> & {
id: "kimi-k2.7-code";
provider: "opencode-go";
};
"kimi-k3": Model<"openai-completions"> & {
id: "kimi-k3";
provider: "opencode-go";
};
"mimo-v2.5": Model<"openai-completions"> & {
id: "mimo-v2.5";
provider: "opencode-go";
};
"mimo-v2.5-pro": Model<"openai-completions"> & {
id: "mimo-v2.5-pro";
provider: "opencode-go";
};
"minimax-m2.7": Model<"openai-completions"> & {
id: "minimax-m2.7";
provider: "opencode-go";
};
"minimax-m3": Model<"anthropic-messages"> & {
id: "minimax-m3";
provider: "opencode-go";
};
"qwen3.6-plus": Model<"openai-completions"> & {
id: "qwen3.6-plus";
provider: "opencode-go";
};
"qwen3.7-max": Model<"anthropic-messages"> & {
id: "qwen3.7-max";
provider: "opencode-go";
};
"qwen3.7-plus": Model<"anthropic-messages"> & {
id: "qwen3.7-plus";
provider: "opencode-go";
};
};
export const OPENCODE_GO_MODELS: ModelCatalog<typeof values, "opencode-go"> =
flattenModelCatalog("opencode-go", values);
+3 -227
View File
@@ -2,231 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/opencode.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const OPENCODE_MODELS = values as {
"big-pickle": Model<"openai-completions"> & {
id: "big-pickle";
provider: "opencode";
};
"claude-fable-5": Model<"anthropic-messages"> & {
id: "claude-fable-5";
provider: "opencode";
};
"claude-haiku-4-5": Model<"anthropic-messages"> & {
id: "claude-haiku-4-5";
provider: "opencode";
};
"claude-opus-4-1": Model<"anthropic-messages"> & {
id: "claude-opus-4-1";
provider: "opencode";
};
"claude-opus-4-5": Model<"anthropic-messages"> & {
id: "claude-opus-4-5";
provider: "opencode";
};
"claude-opus-4-6": Model<"anthropic-messages"> & {
id: "claude-opus-4-6";
provider: "opencode";
};
"claude-opus-4-7": Model<"anthropic-messages"> & {
id: "claude-opus-4-7";
provider: "opencode";
};
"claude-opus-4-8": Model<"anthropic-messages"> & {
id: "claude-opus-4-8";
provider: "opencode";
};
"claude-sonnet-4": Model<"anthropic-messages"> & {
id: "claude-sonnet-4";
provider: "opencode";
};
"claude-sonnet-4-5": Model<"anthropic-messages"> & {
id: "claude-sonnet-4-5";
provider: "opencode";
};
"claude-sonnet-4-6": Model<"anthropic-messages"> & {
id: "claude-sonnet-4-6";
provider: "opencode";
};
"claude-sonnet-5": Model<"anthropic-messages"> & {
id: "claude-sonnet-5";
provider: "opencode";
};
"deepseek-v4-flash": Model<"openai-completions"> & {
id: "deepseek-v4-flash";
provider: "opencode";
};
"deepseek-v4-flash-free": Model<"openai-completions"> & {
id: "deepseek-v4-flash-free";
provider: "opencode";
};
"deepseek-v4-pro": Model<"openai-completions"> & {
id: "deepseek-v4-pro";
provider: "opencode";
};
"gemini-3-flash": Model<"google-generative-ai"> & {
id: "gemini-3-flash";
provider: "opencode";
};
"gemini-3.1-pro": Model<"google-generative-ai"> & {
id: "gemini-3.1-pro";
provider: "opencode";
};
"gemini-3.5-flash": Model<"google-generative-ai"> & {
id: "gemini-3.5-flash";
provider: "opencode";
};
"gemini-3.5-flash-lite": Model<"google-generative-ai"> & {
id: "gemini-3.5-flash-lite";
provider: "opencode";
};
"gemini-3.6-flash": Model<"google-generative-ai"> & {
id: "gemini-3.6-flash";
provider: "opencode";
};
"glm-5": Model<"openai-completions"> & {
id: "glm-5";
provider: "opencode";
};
"glm-5.1": Model<"openai-completions"> & {
id: "glm-5.1";
provider: "opencode";
};
"glm-5.2": Model<"openai-completions"> & {
id: "glm-5.2";
provider: "opencode";
};
"gpt-5": Model<"openai-responses"> & {
id: "gpt-5";
provider: "opencode";
};
"gpt-5-codex": Model<"openai-responses"> & {
id: "gpt-5-codex";
provider: "opencode";
};
"gpt-5-nano": Model<"openai-responses"> & {
id: "gpt-5-nano";
provider: "opencode";
};
"gpt-5.1": Model<"openai-responses"> & {
id: "gpt-5.1";
provider: "opencode";
};
"gpt-5.1-codex": Model<"openai-responses"> & {
id: "gpt-5.1-codex";
provider: "opencode";
};
"gpt-5.1-codex-max": Model<"openai-responses"> & {
id: "gpt-5.1-codex-max";
provider: "opencode";
};
"gpt-5.1-codex-mini": Model<"openai-responses"> & {
id: "gpt-5.1-codex-mini";
provider: "opencode";
};
"gpt-5.2": Model<"openai-responses"> & {
id: "gpt-5.2";
provider: "opencode";
};
"gpt-5.2-codex": Model<"openai-responses"> & {
id: "gpt-5.2-codex";
provider: "opencode";
};
"gpt-5.3-codex": Model<"openai-responses"> & {
id: "gpt-5.3-codex";
provider: "opencode";
};
"gpt-5.4": Model<"openai-responses"> & {
id: "gpt-5.4";
provider: "opencode";
};
"gpt-5.4-mini": Model<"openai-responses"> & {
id: "gpt-5.4-mini";
provider: "opencode";
};
"gpt-5.4-nano": Model<"openai-responses"> & {
id: "gpt-5.4-nano";
provider: "opencode";
};
"gpt-5.4-pro": Model<"openai-responses"> & {
id: "gpt-5.4-pro";
provider: "opencode";
};
"gpt-5.5": Model<"openai-responses"> & {
id: "gpt-5.5";
provider: "opencode";
};
"gpt-5.5-pro": Model<"openai-responses"> & {
id: "gpt-5.5-pro";
provider: "opencode";
};
"gpt-5.6-luna": Model<"openai-responses"> & {
id: "gpt-5.6-luna";
provider: "opencode";
};
"gpt-5.6-sol": Model<"openai-responses"> & {
id: "gpt-5.6-sol";
provider: "opencode";
};
"gpt-5.6-terra": Model<"openai-responses"> & {
id: "gpt-5.6-terra";
provider: "opencode";
};
"grok-4.5": Model<"openai-responses"> & {
id: "grok-4.5";
provider: "opencode";
};
"grok-build-0.1": Model<"openai-completions"> & {
id: "grok-build-0.1";
provider: "opencode";
};
"kimi-k2.5": Model<"openai-completions"> & {
id: "kimi-k2.5";
provider: "opencode";
};
"kimi-k2.6": Model<"openai-completions"> & {
id: "kimi-k2.6";
provider: "opencode";
};
"kimi-k2.7-code": Model<"openai-completions"> & {
id: "kimi-k2.7-code";
provider: "opencode";
};
"laguna-s-2.1-free": Model<"openai-completions"> & {
id: "laguna-s-2.1-free";
provider: "opencode";
};
"mimo-v2.5-free": Model<"openai-completions"> & {
id: "mimo-v2.5-free";
provider: "opencode";
};
"minimax-m2.5": Model<"openai-completions"> & {
id: "minimax-m2.5";
provider: "opencode";
};
"minimax-m2.7": Model<"openai-completions"> & {
id: "minimax-m2.7";
provider: "opencode";
};
"minimax-m3": Model<"openai-completions"> & {
id: "minimax-m3";
provider: "opencode";
};
"nemotron-3-ultra-free": Model<"openai-completions"> & {
id: "nemotron-3-ultra-free";
provider: "opencode";
};
"north-mini-code-free": Model<"openai-completions"> & {
id: "north-mini-code-free";
provider: "opencode";
};
"qwen3.5-plus": Model<"anthropic-messages"> & {
id: "qwen3.5-plus";
provider: "opencode";
};
"qwen3.6-plus": Model<"anthropic-messages"> & {
id: "qwen3.6-plus";
provider: "opencode";
};
};
export const OPENCODE_MODELS: ModelCatalog<typeof values, "opencode"> =
flattenModelCatalog("opencode", values);
+10 -2
View File
@@ -1,5 +1,6 @@
import { openrouterImagesApi } from "../api/openrouter-images.lazy.ts";
import { envApiKeyAuth } from "../auth/helpers.ts";
import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts";
import { loadOpenRouterOAuth } from "../auth/oauth/load.ts";
import { IMAGE_MODELS } from "../image-models.generated.ts";
import { createImagesProvider, type ImagesProvider } from "../images-models.ts";
@@ -7,7 +8,14 @@ export function openrouterImagesProvider(): ImagesProvider {
return createImagesProvider({
id: "openrouter",
name: "OpenRouter",
auth: { apiKey: envApiKeyAuth("OpenRouter API key", ["OPENROUTER_API_KEY"]) },
auth: {
apiKey: envApiKeyAuth("OpenRouter API key", ["OPENROUTER_API_KEY"]),
oauth: lazyOAuth({
name: "OpenRouter OAuth",
loginLabel: "Sign in with OpenRouter",
load: loadOpenRouterOAuth,
}),
},
models: Object.values(IMAGE_MODELS.openrouter),
api: openrouterImagesApi(),
});
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -1,5 +1,6 @@
import { openAICompletionsApi } from "../api/openai-completions.lazy.ts";
import { envApiKeyAuth } from "../auth/helpers.ts";
import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts";
import { loadOpenRouterOAuth } from "../auth/oauth/load.ts";
import { createProvider, type Provider } from "../models.ts";
import { OPENROUTER_MODELS } from "./openrouter.models.ts";
@@ -8,7 +9,14 @@ export function openrouterProvider(): Provider<"openai-completions"> {
id: "openrouter",
name: "OpenRouter",
baseUrl: "https://openrouter.ai/api/v1",
auth: { apiKey: envApiKeyAuth("OpenRouter API key", ["OPENROUTER_API_KEY"]) },
auth: {
apiKey: envApiKeyAuth("OpenRouter API key", ["OPENROUTER_API_KEY"]),
oauth: lazyOAuth({
name: "OpenRouter OAuth",
loginLabel: "Sign in with OpenRouter",
load: loadOpenRouterOAuth,
}),
},
models: Object.values(OPENROUTER_MODELS),
api: openAICompletionsApi(),
});
@@ -2,67 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/qwen-token-plan-cn.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const QWEN_TOKEN_PLAN_CN_MODELS = values as {
"MiniMax-M2.5": Model<"openai-completions"> & {
id: "MiniMax-M2.5";
provider: "qwen-token-plan-cn";
};
"deepseek-v3.2": Model<"openai-completions"> & {
id: "deepseek-v3.2";
provider: "qwen-token-plan-cn";
};
"deepseek-v4-flash": Model<"openai-completions"> & {
id: "deepseek-v4-flash";
provider: "qwen-token-plan-cn";
};
"deepseek-v4-pro": Model<"openai-completions"> & {
id: "deepseek-v4-pro";
provider: "qwen-token-plan-cn";
};
"glm-5": Model<"openai-completions"> & {
id: "glm-5";
provider: "qwen-token-plan-cn";
};
"glm-5.1": Model<"openai-completions"> & {
id: "glm-5.1";
provider: "qwen-token-plan-cn";
};
"glm-5.2": Model<"openai-completions"> & {
id: "glm-5.2";
provider: "qwen-token-plan-cn";
};
"kimi-k2.5": Model<"openai-completions"> & {
id: "kimi-k2.5";
provider: "qwen-token-plan-cn";
};
"kimi-k2.6": Model<"openai-completions"> & {
id: "kimi-k2.6";
provider: "qwen-token-plan-cn";
};
"kimi-k2.7-code": Model<"openai-completions"> & {
id: "kimi-k2.7-code";
provider: "qwen-token-plan-cn";
};
"qwen3.6-flash": Model<"openai-completions"> & {
id: "qwen3.6-flash";
provider: "qwen-token-plan-cn";
};
"qwen3.6-plus": Model<"openai-completions"> & {
id: "qwen3.6-plus";
provider: "qwen-token-plan-cn";
};
"qwen3.7-max": Model<"openai-completions"> & {
id: "qwen3.7-max";
provider: "qwen-token-plan-cn";
};
"qwen3.7-plus": Model<"openai-completions"> & {
id: "qwen3.7-plus";
provider: "qwen-token-plan-cn";
};
"qwen3.8-max-preview": Model<"openai-completions"> & {
id: "qwen3.8-max-preview";
provider: "qwen-token-plan-cn";
};
};
export const QWEN_TOKEN_PLAN_CN_MODELS: ModelCatalog<typeof values, "qwen-token-plan-cn"> =
flattenModelCatalog("qwen-token-plan-cn", values);
@@ -2,67 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/qwen-token-plan.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const QWEN_TOKEN_PLAN_MODELS = values as {
"MiniMax-M2.5": Model<"openai-completions"> & {
id: "MiniMax-M2.5";
provider: "qwen-token-plan";
};
"deepseek-v3.2": Model<"openai-completions"> & {
id: "deepseek-v3.2";
provider: "qwen-token-plan";
};
"deepseek-v4-flash": Model<"openai-completions"> & {
id: "deepseek-v4-flash";
provider: "qwen-token-plan";
};
"deepseek-v4-pro": Model<"openai-completions"> & {
id: "deepseek-v4-pro";
provider: "qwen-token-plan";
};
"glm-5": Model<"openai-completions"> & {
id: "glm-5";
provider: "qwen-token-plan";
};
"glm-5.1": Model<"openai-completions"> & {
id: "glm-5.1";
provider: "qwen-token-plan";
};
"glm-5.2": Model<"openai-completions"> & {
id: "glm-5.2";
provider: "qwen-token-plan";
};
"kimi-k2.5": Model<"openai-completions"> & {
id: "kimi-k2.5";
provider: "qwen-token-plan";
};
"kimi-k2.6": Model<"openai-completions"> & {
id: "kimi-k2.6";
provider: "qwen-token-plan";
};
"kimi-k2.7-code": Model<"openai-completions"> & {
id: "kimi-k2.7-code";
provider: "qwen-token-plan";
};
"qwen3.6-flash": Model<"openai-completions"> & {
id: "qwen3.6-flash";
provider: "qwen-token-plan";
};
"qwen3.6-plus": Model<"openai-completions"> & {
id: "qwen3.6-plus";
provider: "qwen-token-plan";
};
"qwen3.7-max": Model<"openai-completions"> & {
id: "qwen3.7-max";
provider: "qwen-token-plan";
};
"qwen3.7-plus": Model<"openai-completions"> & {
id: "qwen3.7-plus";
provider: "qwen-token-plan";
};
"qwen3.8-max-preview": Model<"openai-completions"> & {
id: "qwen3.8-max-preview";
provider: "qwen-token-plan";
};
};
export const QWEN_TOKEN_PLAN_MODELS: ModelCatalog<typeof values, "qwen-token-plan"> =
flattenModelCatalog("qwen-token-plan", values);
+3 -67
View File
@@ -2,71 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/together.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const TOGETHER_MODELS = values as {
"MiniMaxAI/MiniMax-M2.7": Model<"openai-completions"> & {
id: "MiniMaxAI/MiniMax-M2.7";
provider: "together";
};
"MiniMaxAI/MiniMax-M3": Model<"openai-completions"> & {
id: "MiniMaxAI/MiniMax-M3";
provider: "together";
};
"Qwen/Qwen2.5-7B-Instruct-Turbo": Model<"openai-completions"> & {
id: "Qwen/Qwen2.5-7B-Instruct-Turbo";
provider: "together";
};
"Qwen/Qwen3.5-9B": Model<"openai-completions"> & {
id: "Qwen/Qwen3.5-9B";
provider: "together";
};
"Qwen/Qwen3.6-Plus": Model<"openai-completions"> & {
id: "Qwen/Qwen3.6-Plus";
provider: "together";
};
"Qwen/Qwen3.7-Max": Model<"openai-completions"> & {
id: "Qwen/Qwen3.7-Max";
provider: "together";
};
"deepseek-ai/DeepSeek-V4-Pro": Model<"openai-completions"> & {
id: "deepseek-ai/DeepSeek-V4-Pro";
provider: "together";
};
"google/gemma-4-31B-it": Model<"openai-completions"> & {
id: "google/gemma-4-31B-it";
provider: "together";
};
"meta-llama/Llama-3.3-70B-Instruct-Turbo": Model<"openai-completions"> & {
id: "meta-llama/Llama-3.3-70B-Instruct-Turbo";
provider: "together";
};
"moonshotai/Kimi-K2.6": Model<"openai-completions"> & {
id: "moonshotai/Kimi-K2.6";
provider: "together";
};
"moonshotai/Kimi-K2.7-Code": Model<"openai-completions"> & {
id: "moonshotai/Kimi-K2.7-Code";
provider: "together";
};
"nvidia/nemotron-3-ultra-550b-a55b": Model<"openai-completions"> & {
id: "nvidia/nemotron-3-ultra-550b-a55b";
provider: "together";
};
"openai/gpt-oss-120b": Model<"openai-completions"> & {
id: "openai/gpt-oss-120b";
provider: "together";
};
"openai/gpt-oss-20b": Model<"openai-completions"> & {
id: "openai/gpt-oss-20b";
provider: "together";
};
"thinkingmachines/Inkling": Model<"openai-completions"> & {
id: "thinkingmachines/Inkling";
provider: "together";
};
"zai-org/GLM-5.2": Model<"openai-completions"> & {
id: "zai-org/GLM-5.2";
provider: "together";
};
};
export const TOGETHER_MODELS: ModelCatalog<typeof values, "together"> =
flattenModelCatalog("together", values);
@@ -2,775 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/vercel-ai-gateway.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const VERCEL_AI_GATEWAY_MODELS = values as {
"alibaba/qwen-3-14b": Model<"anthropic-messages"> & {
id: "alibaba/qwen-3-14b";
provider: "vercel-ai-gateway";
};
"alibaba/qwen-3-235b": Model<"anthropic-messages"> & {
id: "alibaba/qwen-3-235b";
provider: "vercel-ai-gateway";
};
"alibaba/qwen-3-30b": Model<"anthropic-messages"> & {
id: "alibaba/qwen-3-30b";
provider: "vercel-ai-gateway";
};
"alibaba/qwen-3-32b": Model<"anthropic-messages"> & {
id: "alibaba/qwen-3-32b";
provider: "vercel-ai-gateway";
};
"alibaba/qwen-3.6-max-preview": Model<"anthropic-messages"> & {
id: "alibaba/qwen-3.6-max-preview";
provider: "vercel-ai-gateway";
};
"alibaba/qwen3-235b-a22b-thinking": Model<"anthropic-messages"> & {
id: "alibaba/qwen3-235b-a22b-thinking";
provider: "vercel-ai-gateway";
};
"alibaba/qwen3-coder": Model<"anthropic-messages"> & {
id: "alibaba/qwen3-coder";
provider: "vercel-ai-gateway";
};
"alibaba/qwen3-coder-30b-a3b": Model<"anthropic-messages"> & {
id: "alibaba/qwen3-coder-30b-a3b";
provider: "vercel-ai-gateway";
};
"alibaba/qwen3-coder-next": Model<"anthropic-messages"> & {
id: "alibaba/qwen3-coder-next";
provider: "vercel-ai-gateway";
};
"alibaba/qwen3-coder-plus": Model<"anthropic-messages"> & {
id: "alibaba/qwen3-coder-plus";
provider: "vercel-ai-gateway";
};
"alibaba/qwen3-max": Model<"anthropic-messages"> & {
id: "alibaba/qwen3-max";
provider: "vercel-ai-gateway";
};
"alibaba/qwen3-max-preview": Model<"anthropic-messages"> & {
id: "alibaba/qwen3-max-preview";
provider: "vercel-ai-gateway";
};
"alibaba/qwen3-max-thinking": Model<"anthropic-messages"> & {
id: "alibaba/qwen3-max-thinking";
provider: "vercel-ai-gateway";
};
"alibaba/qwen3-next-80b-a3b-instruct": Model<"anthropic-messages"> & {
id: "alibaba/qwen3-next-80b-a3b-instruct";
provider: "vercel-ai-gateway";
};
"alibaba/qwen3-next-80b-a3b-thinking": Model<"anthropic-messages"> & {
id: "alibaba/qwen3-next-80b-a3b-thinking";
provider: "vercel-ai-gateway";
};
"alibaba/qwen3-vl-235b-a22b-instruct": Model<"anthropic-messages"> & {
id: "alibaba/qwen3-vl-235b-a22b-instruct";
provider: "vercel-ai-gateway";
};
"alibaba/qwen3-vl-instruct": Model<"anthropic-messages"> & {
id: "alibaba/qwen3-vl-instruct";
provider: "vercel-ai-gateway";
};
"alibaba/qwen3-vl-thinking": Model<"anthropic-messages"> & {
id: "alibaba/qwen3-vl-thinking";
provider: "vercel-ai-gateway";
};
"alibaba/qwen3.5-flash": Model<"anthropic-messages"> & {
id: "alibaba/qwen3.5-flash";
provider: "vercel-ai-gateway";
};
"alibaba/qwen3.5-plus": Model<"anthropic-messages"> & {
id: "alibaba/qwen3.5-plus";
provider: "vercel-ai-gateway";
};
"alibaba/qwen3.6-27b": Model<"anthropic-messages"> & {
id: "alibaba/qwen3.6-27b";
provider: "vercel-ai-gateway";
};
"alibaba/qwen3.6-plus": Model<"anthropic-messages"> & {
id: "alibaba/qwen3.6-plus";
provider: "vercel-ai-gateway";
};
"alibaba/qwen3.7-max": Model<"anthropic-messages"> & {
id: "alibaba/qwen3.7-max";
provider: "vercel-ai-gateway";
};
"alibaba/qwen3.7-plus": Model<"anthropic-messages"> & {
id: "alibaba/qwen3.7-plus";
provider: "vercel-ai-gateway";
};
"amazon/nova-2-lite": Model<"anthropic-messages"> & {
id: "amazon/nova-2-lite";
provider: "vercel-ai-gateway";
};
"amazon/nova-lite": Model<"anthropic-messages"> & {
id: "amazon/nova-lite";
provider: "vercel-ai-gateway";
};
"amazon/nova-micro": Model<"anthropic-messages"> & {
id: "amazon/nova-micro";
provider: "vercel-ai-gateway";
};
"amazon/nova-pro": Model<"anthropic-messages"> & {
id: "amazon/nova-pro";
provider: "vercel-ai-gateway";
};
"anthropic/claude-3-haiku": Model<"anthropic-messages"> & {
id: "anthropic/claude-3-haiku";
provider: "vercel-ai-gateway";
};
"anthropic/claude-fable-5": Model<"anthropic-messages"> & {
id: "anthropic/claude-fable-5";
provider: "vercel-ai-gateway";
};
"anthropic/claude-haiku-4.5": Model<"anthropic-messages"> & {
id: "anthropic/claude-haiku-4.5";
provider: "vercel-ai-gateway";
};
"anthropic/claude-opus-4": Model<"anthropic-messages"> & {
id: "anthropic/claude-opus-4";
provider: "vercel-ai-gateway";
};
"anthropic/claude-opus-4.1": Model<"anthropic-messages"> & {
id: "anthropic/claude-opus-4.1";
provider: "vercel-ai-gateway";
};
"anthropic/claude-opus-4.5": Model<"anthropic-messages"> & {
id: "anthropic/claude-opus-4.5";
provider: "vercel-ai-gateway";
};
"anthropic/claude-opus-4.6": Model<"anthropic-messages"> & {
id: "anthropic/claude-opus-4.6";
provider: "vercel-ai-gateway";
};
"anthropic/claude-opus-4.7": Model<"anthropic-messages"> & {
id: "anthropic/claude-opus-4.7";
provider: "vercel-ai-gateway";
};
"anthropic/claude-opus-4.7-fast": Model<"anthropic-messages"> & {
id: "anthropic/claude-opus-4.7-fast";
provider: "vercel-ai-gateway";
};
"anthropic/claude-opus-4.8": Model<"anthropic-messages"> & {
id: "anthropic/claude-opus-4.8";
provider: "vercel-ai-gateway";
};
"anthropic/claude-opus-4.8-fast": Model<"anthropic-messages"> & {
id: "anthropic/claude-opus-4.8-fast";
provider: "vercel-ai-gateway";
};
"anthropic/claude-sonnet-4": Model<"anthropic-messages"> & {
id: "anthropic/claude-sonnet-4";
provider: "vercel-ai-gateway";
};
"anthropic/claude-sonnet-4.5": Model<"anthropic-messages"> & {
id: "anthropic/claude-sonnet-4.5";
provider: "vercel-ai-gateway";
};
"anthropic/claude-sonnet-4.6": Model<"anthropic-messages"> & {
id: "anthropic/claude-sonnet-4.6";
provider: "vercel-ai-gateway";
};
"anthropic/claude-sonnet-5": Model<"anthropic-messages"> & {
id: "anthropic/claude-sonnet-5";
provider: "vercel-ai-gateway";
};
"arcee-ai/trinity-large-thinking": Model<"anthropic-messages"> & {
id: "arcee-ai/trinity-large-thinking";
provider: "vercel-ai-gateway";
};
"arcee-ai/trinity-mini": Model<"anthropic-messages"> & {
id: "arcee-ai/trinity-mini";
provider: "vercel-ai-gateway";
};
"bytedance/seed-1.6": Model<"anthropic-messages"> & {
id: "bytedance/seed-1.6";
provider: "vercel-ai-gateway";
};
"bytedance/seed-1.8": Model<"anthropic-messages"> & {
id: "bytedance/seed-1.8";
provider: "vercel-ai-gateway";
};
"cohere/command-a": Model<"anthropic-messages"> & {
id: "cohere/command-a";
provider: "vercel-ai-gateway";
};
"deepseek/deepseek-r1": Model<"anthropic-messages"> & {
id: "deepseek/deepseek-r1";
provider: "vercel-ai-gateway";
};
"deepseek/deepseek-v3": Model<"anthropic-messages"> & {
id: "deepseek/deepseek-v3";
provider: "vercel-ai-gateway";
};
"deepseek/deepseek-v3.1": Model<"anthropic-messages"> & {
id: "deepseek/deepseek-v3.1";
provider: "vercel-ai-gateway";
};
"deepseek/deepseek-v3.1-terminus": Model<"anthropic-messages"> & {
id: "deepseek/deepseek-v3.1-terminus";
provider: "vercel-ai-gateway";
};
"deepseek/deepseek-v3.2": Model<"anthropic-messages"> & {
id: "deepseek/deepseek-v3.2";
provider: "vercel-ai-gateway";
};
"deepseek/deepseek-v3.2-thinking": Model<"anthropic-messages"> & {
id: "deepseek/deepseek-v3.2-thinking";
provider: "vercel-ai-gateway";
};
"deepseek/deepseek-v4-flash": Model<"anthropic-messages"> & {
id: "deepseek/deepseek-v4-flash";
provider: "vercel-ai-gateway";
};
"deepseek/deepseek-v4-pro": Model<"anthropic-messages"> & {
id: "deepseek/deepseek-v4-pro";
provider: "vercel-ai-gateway";
};
"google/gemini-2.5-flash": Model<"anthropic-messages"> & {
id: "google/gemini-2.5-flash";
provider: "vercel-ai-gateway";
};
"google/gemini-2.5-flash-lite": Model<"anthropic-messages"> & {
id: "google/gemini-2.5-flash-lite";
provider: "vercel-ai-gateway";
};
"google/gemini-2.5-pro": Model<"anthropic-messages"> & {
id: "google/gemini-2.5-pro";
provider: "vercel-ai-gateway";
};
"google/gemini-3-flash": Model<"anthropic-messages"> & {
id: "google/gemini-3-flash";
provider: "vercel-ai-gateway";
};
"google/gemini-3-pro-preview": Model<"anthropic-messages"> & {
id: "google/gemini-3-pro-preview";
provider: "vercel-ai-gateway";
};
"google/gemini-3.1-flash-lite": Model<"anthropic-messages"> & {
id: "google/gemini-3.1-flash-lite";
provider: "vercel-ai-gateway";
};
"google/gemini-3.1-flash-lite-preview": Model<"anthropic-messages"> & {
id: "google/gemini-3.1-flash-lite-preview";
provider: "vercel-ai-gateway";
};
"google/gemini-3.1-pro-preview": Model<"anthropic-messages"> & {
id: "google/gemini-3.1-pro-preview";
provider: "vercel-ai-gateway";
};
"google/gemini-3.5-flash": Model<"anthropic-messages"> & {
id: "google/gemini-3.5-flash";
provider: "vercel-ai-gateway";
};
"google/gemini-3.5-flash-lite": Model<"anthropic-messages"> & {
id: "google/gemini-3.5-flash-lite";
provider: "vercel-ai-gateway";
};
"google/gemini-3.6-flash": Model<"anthropic-messages"> & {
id: "google/gemini-3.6-flash";
provider: "vercel-ai-gateway";
};
"google/gemma-4-26b-a4b-it": Model<"anthropic-messages"> & {
id: "google/gemma-4-26b-a4b-it";
provider: "vercel-ai-gateway";
};
"google/gemma-4-31b-it": Model<"anthropic-messages"> & {
id: "google/gemma-4-31b-it";
provider: "vercel-ai-gateway";
};
"inception/mercury-2": Model<"anthropic-messages"> & {
id: "inception/mercury-2";
provider: "vercel-ai-gateway";
};
"inception/mercury-coder-small": Model<"anthropic-messages"> & {
id: "inception/mercury-coder-small";
provider: "vercel-ai-gateway";
};
"interfaze/interfaze-beta": Model<"anthropic-messages"> & {
id: "interfaze/interfaze-beta";
provider: "vercel-ai-gateway";
};
"kwaipilot/kat-coder-air-v2.5": Model<"anthropic-messages"> & {
id: "kwaipilot/kat-coder-air-v2.5";
provider: "vercel-ai-gateway";
};
"kwaipilot/kat-coder-pro-v1": Model<"anthropic-messages"> & {
id: "kwaipilot/kat-coder-pro-v1";
provider: "vercel-ai-gateway";
};
"kwaipilot/kat-coder-pro-v2": Model<"anthropic-messages"> & {
id: "kwaipilot/kat-coder-pro-v2";
provider: "vercel-ai-gateway";
};
"kwaipilot/kat-coder-pro-v2.5": Model<"anthropic-messages"> & {
id: "kwaipilot/kat-coder-pro-v2.5";
provider: "vercel-ai-gateway";
};
"meta/llama-3.1-70b": Model<"anthropic-messages"> & {
id: "meta/llama-3.1-70b";
provider: "vercel-ai-gateway";
};
"meta/llama-3.1-8b": Model<"anthropic-messages"> & {
id: "meta/llama-3.1-8b";
provider: "vercel-ai-gateway";
};
"meta/llama-3.3-70b": Model<"anthropic-messages"> & {
id: "meta/llama-3.3-70b";
provider: "vercel-ai-gateway";
};
"meta/llama-4-maverick": Model<"anthropic-messages"> & {
id: "meta/llama-4-maverick";
provider: "vercel-ai-gateway";
};
"meta/llama-4-scout": Model<"anthropic-messages"> & {
id: "meta/llama-4-scout";
provider: "vercel-ai-gateway";
};
"meta/muse-spark-1.1": Model<"anthropic-messages"> & {
id: "meta/muse-spark-1.1";
provider: "vercel-ai-gateway";
};
"minimax/minimax-m2": Model<"anthropic-messages"> & {
id: "minimax/minimax-m2";
provider: "vercel-ai-gateway";
};
"minimax/minimax-m2.1": Model<"anthropic-messages"> & {
id: "minimax/minimax-m2.1";
provider: "vercel-ai-gateway";
};
"minimax/minimax-m2.1-lightning": Model<"anthropic-messages"> & {
id: "minimax/minimax-m2.1-lightning";
provider: "vercel-ai-gateway";
};
"minimax/minimax-m2.5": Model<"anthropic-messages"> & {
id: "minimax/minimax-m2.5";
provider: "vercel-ai-gateway";
};
"minimax/minimax-m2.5-highspeed": Model<"anthropic-messages"> & {
id: "minimax/minimax-m2.5-highspeed";
provider: "vercel-ai-gateway";
};
"minimax/minimax-m2.7": Model<"anthropic-messages"> & {
id: "minimax/minimax-m2.7";
provider: "vercel-ai-gateway";
};
"minimax/minimax-m2.7-highspeed": Model<"anthropic-messages"> & {
id: "minimax/minimax-m2.7-highspeed";
provider: "vercel-ai-gateway";
};
"minimax/minimax-m3": Model<"anthropic-messages"> & {
id: "minimax/minimax-m3";
provider: "vercel-ai-gateway";
};
"mistral/codestral": Model<"anthropic-messages"> & {
id: "mistral/codestral";
provider: "vercel-ai-gateway";
};
"mistral/devstral-2": Model<"anthropic-messages"> & {
id: "mistral/devstral-2";
provider: "vercel-ai-gateway";
};
"mistral/devstral-small-2": Model<"anthropic-messages"> & {
id: "mistral/devstral-small-2";
provider: "vercel-ai-gateway";
};
"mistral/magistral-medium": Model<"anthropic-messages"> & {
id: "mistral/magistral-medium";
provider: "vercel-ai-gateway";
};
"mistral/magistral-small": Model<"anthropic-messages"> & {
id: "mistral/magistral-small";
provider: "vercel-ai-gateway";
};
"mistral/ministral-14b": Model<"anthropic-messages"> & {
id: "mistral/ministral-14b";
provider: "vercel-ai-gateway";
};
"mistral/ministral-3b": Model<"anthropic-messages"> & {
id: "mistral/ministral-3b";
provider: "vercel-ai-gateway";
};
"mistral/ministral-8b": Model<"anthropic-messages"> & {
id: "mistral/ministral-8b";
provider: "vercel-ai-gateway";
};
"mistral/mistral-large-3": Model<"anthropic-messages"> & {
id: "mistral/mistral-large-3";
provider: "vercel-ai-gateway";
};
"mistral/mistral-medium": Model<"anthropic-messages"> & {
id: "mistral/mistral-medium";
provider: "vercel-ai-gateway";
};
"mistral/mistral-medium-3.5": Model<"anthropic-messages"> & {
id: "mistral/mistral-medium-3.5";
provider: "vercel-ai-gateway";
};
"mistral/mistral-nemo": Model<"anthropic-messages"> & {
id: "mistral/mistral-nemo";
provider: "vercel-ai-gateway";
};
"mistral/mistral-small": Model<"anthropic-messages"> & {
id: "mistral/mistral-small";
provider: "vercel-ai-gateway";
};
"mistral/pixtral-12b": Model<"anthropic-messages"> & {
id: "mistral/pixtral-12b";
provider: "vercel-ai-gateway";
};
"moonshotai/kimi-k2": Model<"anthropic-messages"> & {
id: "moonshotai/kimi-k2";
provider: "vercel-ai-gateway";
};
"moonshotai/kimi-k2-thinking": Model<"anthropic-messages"> & {
id: "moonshotai/kimi-k2-thinking";
provider: "vercel-ai-gateway";
};
"moonshotai/kimi-k2.5": Model<"anthropic-messages"> & {
id: "moonshotai/kimi-k2.5";
provider: "vercel-ai-gateway";
};
"moonshotai/kimi-k2.6": Model<"anthropic-messages"> & {
id: "moonshotai/kimi-k2.6";
provider: "vercel-ai-gateway";
};
"moonshotai/kimi-k2.7-code": Model<"anthropic-messages"> & {
id: "moonshotai/kimi-k2.7-code";
provider: "vercel-ai-gateway";
};
"moonshotai/kimi-k2.7-code-highspeed": Model<"anthropic-messages"> & {
id: "moonshotai/kimi-k2.7-code-highspeed";
provider: "vercel-ai-gateway";
};
"moonshotai/kimi-k3": Model<"anthropic-messages"> & {
id: "moonshotai/kimi-k3";
provider: "vercel-ai-gateway";
};
"nvidia/nemotron-3-nano-30b-a3b": Model<"anthropic-messages"> & {
id: "nvidia/nemotron-3-nano-30b-a3b";
provider: "vercel-ai-gateway";
};
"nvidia/nemotron-3-super-120b-a12b": Model<"anthropic-messages"> & {
id: "nvidia/nemotron-3-super-120b-a12b";
provider: "vercel-ai-gateway";
};
"nvidia/nemotron-3-ultra-550b-a55b": Model<"anthropic-messages"> & {
id: "nvidia/nemotron-3-ultra-550b-a55b";
provider: "vercel-ai-gateway";
};
"nvidia/nemotron-nano-12b-v2-vl": Model<"anthropic-messages"> & {
id: "nvidia/nemotron-nano-12b-v2-vl";
provider: "vercel-ai-gateway";
};
"nvidia/nemotron-nano-9b-v2": Model<"anthropic-messages"> & {
id: "nvidia/nemotron-nano-9b-v2";
provider: "vercel-ai-gateway";
};
"openai/gpt-3.5-turbo": Model<"anthropic-messages"> & {
id: "openai/gpt-3.5-turbo";
provider: "vercel-ai-gateway";
};
"openai/gpt-4-turbo": Model<"anthropic-messages"> & {
id: "openai/gpt-4-turbo";
provider: "vercel-ai-gateway";
};
"openai/gpt-4.1": Model<"anthropic-messages"> & {
id: "openai/gpt-4.1";
provider: "vercel-ai-gateway";
};
"openai/gpt-4.1-mini": Model<"anthropic-messages"> & {
id: "openai/gpt-4.1-mini";
provider: "vercel-ai-gateway";
};
"openai/gpt-4.1-nano": Model<"anthropic-messages"> & {
id: "openai/gpt-4.1-nano";
provider: "vercel-ai-gateway";
};
"openai/gpt-4o": Model<"anthropic-messages"> & {
id: "openai/gpt-4o";
provider: "vercel-ai-gateway";
};
"openai/gpt-4o-mini": Model<"anthropic-messages"> & {
id: "openai/gpt-4o-mini";
provider: "vercel-ai-gateway";
};
"openai/gpt-5": Model<"anthropic-messages"> & {
id: "openai/gpt-5";
provider: "vercel-ai-gateway";
};
"openai/gpt-5-chat": Model<"anthropic-messages"> & {
id: "openai/gpt-5-chat";
provider: "vercel-ai-gateway";
};
"openai/gpt-5-codex": Model<"anthropic-messages"> & {
id: "openai/gpt-5-codex";
provider: "vercel-ai-gateway";
};
"openai/gpt-5-mini": Model<"anthropic-messages"> & {
id: "openai/gpt-5-mini";
provider: "vercel-ai-gateway";
};
"openai/gpt-5-nano": Model<"anthropic-messages"> & {
id: "openai/gpt-5-nano";
provider: "vercel-ai-gateway";
};
"openai/gpt-5-pro": Model<"anthropic-messages"> & {
id: "openai/gpt-5-pro";
provider: "vercel-ai-gateway";
};
"openai/gpt-5.1-codex": Model<"anthropic-messages"> & {
id: "openai/gpt-5.1-codex";
provider: "vercel-ai-gateway";
};
"openai/gpt-5.1-codex-max": Model<"anthropic-messages"> & {
id: "openai/gpt-5.1-codex-max";
provider: "vercel-ai-gateway";
};
"openai/gpt-5.1-codex-mini": Model<"anthropic-messages"> & {
id: "openai/gpt-5.1-codex-mini";
provider: "vercel-ai-gateway";
};
"openai/gpt-5.1-instant": Model<"anthropic-messages"> & {
id: "openai/gpt-5.1-instant";
provider: "vercel-ai-gateway";
};
"openai/gpt-5.1-thinking": Model<"anthropic-messages"> & {
id: "openai/gpt-5.1-thinking";
provider: "vercel-ai-gateway";
};
"openai/gpt-5.2": Model<"anthropic-messages"> & {
id: "openai/gpt-5.2";
provider: "vercel-ai-gateway";
};
"openai/gpt-5.2-chat": Model<"anthropic-messages"> & {
id: "openai/gpt-5.2-chat";
provider: "vercel-ai-gateway";
};
"openai/gpt-5.2-codex": Model<"anthropic-messages"> & {
id: "openai/gpt-5.2-codex";
provider: "vercel-ai-gateway";
};
"openai/gpt-5.2-pro": Model<"anthropic-messages"> & {
id: "openai/gpt-5.2-pro";
provider: "vercel-ai-gateway";
};
"openai/gpt-5.3-chat": Model<"anthropic-messages"> & {
id: "openai/gpt-5.3-chat";
provider: "vercel-ai-gateway";
};
"openai/gpt-5.3-codex": Model<"anthropic-messages"> & {
id: "openai/gpt-5.3-codex";
provider: "vercel-ai-gateway";
};
"openai/gpt-5.4": Model<"anthropic-messages"> & {
id: "openai/gpt-5.4";
provider: "vercel-ai-gateway";
};
"openai/gpt-5.4-mini": Model<"anthropic-messages"> & {
id: "openai/gpt-5.4-mini";
provider: "vercel-ai-gateway";
};
"openai/gpt-5.4-nano": Model<"anthropic-messages"> & {
id: "openai/gpt-5.4-nano";
provider: "vercel-ai-gateway";
};
"openai/gpt-5.4-pro": Model<"anthropic-messages"> & {
id: "openai/gpt-5.4-pro";
provider: "vercel-ai-gateway";
};
"openai/gpt-5.5": Model<"anthropic-messages"> & {
id: "openai/gpt-5.5";
provider: "vercel-ai-gateway";
};
"openai/gpt-5.5-pro": Model<"anthropic-messages"> & {
id: "openai/gpt-5.5-pro";
provider: "vercel-ai-gateway";
};
"openai/gpt-5.6-luna": Model<"anthropic-messages"> & {
id: "openai/gpt-5.6-luna";
provider: "vercel-ai-gateway";
};
"openai/gpt-5.6-sol": Model<"anthropic-messages"> & {
id: "openai/gpt-5.6-sol";
provider: "vercel-ai-gateway";
};
"openai/gpt-5.6-terra": Model<"anthropic-messages"> & {
id: "openai/gpt-5.6-terra";
provider: "vercel-ai-gateway";
};
"openai/gpt-oss-120b": Model<"anthropic-messages"> & {
id: "openai/gpt-oss-120b";
provider: "vercel-ai-gateway";
};
"openai/gpt-oss-20b": Model<"anthropic-messages"> & {
id: "openai/gpt-oss-20b";
provider: "vercel-ai-gateway";
};
"openai/gpt-oss-safeguard-20b": Model<"anthropic-messages"> & {
id: "openai/gpt-oss-safeguard-20b";
provider: "vercel-ai-gateway";
};
"openai/o1": Model<"anthropic-messages"> & {
id: "openai/o1";
provider: "vercel-ai-gateway";
};
"openai/o3": Model<"anthropic-messages"> & {
id: "openai/o3";
provider: "vercel-ai-gateway";
};
"openai/o3-deep-research": Model<"anthropic-messages"> & {
id: "openai/o3-deep-research";
provider: "vercel-ai-gateway";
};
"openai/o3-mini": Model<"anthropic-messages"> & {
id: "openai/o3-mini";
provider: "vercel-ai-gateway";
};
"openai/o3-pro": Model<"anthropic-messages"> & {
id: "openai/o3-pro";
provider: "vercel-ai-gateway";
};
"openai/o4-mini": Model<"anthropic-messages"> & {
id: "openai/o4-mini";
provider: "vercel-ai-gateway";
};
"poolside/laguna-s-2.1": Model<"anthropic-messages"> & {
id: "poolside/laguna-s-2.1";
provider: "vercel-ai-gateway";
};
"poolside/laguna-s-2.1-free": Model<"anthropic-messages"> & {
id: "poolside/laguna-s-2.1-free";
provider: "vercel-ai-gateway";
};
"sakana/fugu-ultra": Model<"anthropic-messages"> & {
id: "sakana/fugu-ultra";
provider: "vercel-ai-gateway";
};
"stepfun/step-3.5-flash": Model<"anthropic-messages"> & {
id: "stepfun/step-3.5-flash";
provider: "vercel-ai-gateway";
};
"stepfun/step-3.7-flash": Model<"anthropic-messages"> & {
id: "stepfun/step-3.7-flash";
provider: "vercel-ai-gateway";
};
"thinkingmachines/inkling": Model<"anthropic-messages"> & {
id: "thinkingmachines/inkling";
provider: "vercel-ai-gateway";
};
"xai/grok-4.1-fast-non-reasoning": Model<"anthropic-messages"> & {
id: "xai/grok-4.1-fast-non-reasoning";
provider: "vercel-ai-gateway";
};
"xai/grok-4.1-fast-reasoning": Model<"anthropic-messages"> & {
id: "xai/grok-4.1-fast-reasoning";
provider: "vercel-ai-gateway";
};
"xai/grok-4.20-multi-agent": Model<"anthropic-messages"> & {
id: "xai/grok-4.20-multi-agent";
provider: "vercel-ai-gateway";
};
"xai/grok-4.20-multi-agent-beta": Model<"anthropic-messages"> & {
id: "xai/grok-4.20-multi-agent-beta";
provider: "vercel-ai-gateway";
};
"xai/grok-4.20-non-reasoning": Model<"anthropic-messages"> & {
id: "xai/grok-4.20-non-reasoning";
provider: "vercel-ai-gateway";
};
"xai/grok-4.20-non-reasoning-beta": Model<"anthropic-messages"> & {
id: "xai/grok-4.20-non-reasoning-beta";
provider: "vercel-ai-gateway";
};
"xai/grok-4.20-reasoning": Model<"anthropic-messages"> & {
id: "xai/grok-4.20-reasoning";
provider: "vercel-ai-gateway";
};
"xai/grok-4.20-reasoning-beta": Model<"anthropic-messages"> & {
id: "xai/grok-4.20-reasoning-beta";
provider: "vercel-ai-gateway";
};
"xai/grok-4.3": Model<"anthropic-messages"> & {
id: "xai/grok-4.3";
provider: "vercel-ai-gateway";
};
"xai/grok-4.5": Model<"anthropic-messages"> & {
id: "xai/grok-4.5";
provider: "vercel-ai-gateway";
};
"xai/grok-build-0.1": Model<"anthropic-messages"> & {
id: "xai/grok-build-0.1";
provider: "vercel-ai-gateway";
};
"xiaomi/mimo-v2.5": Model<"anthropic-messages"> & {
id: "xiaomi/mimo-v2.5";
provider: "vercel-ai-gateway";
};
"xiaomi/mimo-v2.5-pro": Model<"anthropic-messages"> & {
id: "xiaomi/mimo-v2.5-pro";
provider: "vercel-ai-gateway";
};
"zai/glm-4.5": Model<"anthropic-messages"> & {
id: "zai/glm-4.5";
provider: "vercel-ai-gateway";
};
"zai/glm-4.5-air": Model<"anthropic-messages"> & {
id: "zai/glm-4.5-air";
provider: "vercel-ai-gateway";
};
"zai/glm-4.5v": Model<"anthropic-messages"> & {
id: "zai/glm-4.5v";
provider: "vercel-ai-gateway";
};
"zai/glm-4.6": Model<"anthropic-messages"> & {
id: "zai/glm-4.6";
provider: "vercel-ai-gateway";
};
"zai/glm-4.6v": Model<"anthropic-messages"> & {
id: "zai/glm-4.6v";
provider: "vercel-ai-gateway";
};
"zai/glm-4.6v-flash": Model<"anthropic-messages"> & {
id: "zai/glm-4.6v-flash";
provider: "vercel-ai-gateway";
};
"zai/glm-4.7": Model<"anthropic-messages"> & {
id: "zai/glm-4.7";
provider: "vercel-ai-gateway";
};
"zai/glm-4.7-flash": Model<"anthropic-messages"> & {
id: "zai/glm-4.7-flash";
provider: "vercel-ai-gateway";
};
"zai/glm-4.7-flashx": Model<"anthropic-messages"> & {
id: "zai/glm-4.7-flashx";
provider: "vercel-ai-gateway";
};
"zai/glm-5": Model<"anthropic-messages"> & {
id: "zai/glm-5";
provider: "vercel-ai-gateway";
};
"zai/glm-5-turbo": Model<"anthropic-messages"> & {
id: "zai/glm-5-turbo";
provider: "vercel-ai-gateway";
};
"zai/glm-5.1": Model<"anthropic-messages"> & {
id: "zai/glm-5.1";
provider: "vercel-ai-gateway";
};
"zai/glm-5.2": Model<"anthropic-messages"> & {
id: "zai/glm-5.2";
provider: "vercel-ai-gateway";
};
"zai/glm-5.2-fast": Model<"anthropic-messages"> & {
id: "zai/glm-5.2-fast";
provider: "vercel-ai-gateway";
};
"zai/glm-5v-turbo": Model<"anthropic-messages"> & {
id: "zai/glm-5v-turbo";
provider: "vercel-ai-gateway";
};
};
export const VERCEL_AI_GATEWAY_MODELS: ModelCatalog<typeof values, "vercel-ai-gateway"> =
flattenModelCatalog("vercel-ai-gateway", values);
+3 -15
View File
@@ -2,19 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/xai.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const XAI_MODELS = values as {
"grok-4.3": Model<"openai-completions"> & {
id: "grok-4.3";
provider: "xai";
};
"grok-4.5": Model<"openai-responses"> & {
id: "grok-4.5";
provider: "xai";
};
"grok-build-0.1": Model<"openai-completions"> & {
id: "grok-build-0.1";
provider: "xai";
};
};
export const XAI_MODELS: ModelCatalog<typeof values, "xai"> =
flattenModelCatalog("xai", values);
@@ -2,19 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/xiaomi-token-plan-ams.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const XIAOMI_TOKEN_PLAN_AMS_MODELS = values as {
"mimo-v2-pro": Model<"openai-completions"> & {
id: "mimo-v2-pro";
provider: "xiaomi-token-plan-ams";
};
"mimo-v2.5": Model<"openai-completions"> & {
id: "mimo-v2.5";
provider: "xiaomi-token-plan-ams";
};
"mimo-v2.5-pro": Model<"openai-completions"> & {
id: "mimo-v2.5-pro";
provider: "xiaomi-token-plan-ams";
};
};
export const XIAOMI_TOKEN_PLAN_AMS_MODELS: ModelCatalog<typeof values, "xiaomi-token-plan-ams"> =
flattenModelCatalog("xiaomi-token-plan-ams", values);
@@ -2,19 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/xiaomi-token-plan-cn.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const XIAOMI_TOKEN_PLAN_CN_MODELS = values as {
"mimo-v2-pro": Model<"openai-completions"> & {
id: "mimo-v2-pro";
provider: "xiaomi-token-plan-cn";
};
"mimo-v2.5": Model<"openai-completions"> & {
id: "mimo-v2.5";
provider: "xiaomi-token-plan-cn";
};
"mimo-v2.5-pro": Model<"openai-completions"> & {
id: "mimo-v2.5-pro";
provider: "xiaomi-token-plan-cn";
};
};
export const XIAOMI_TOKEN_PLAN_CN_MODELS: ModelCatalog<typeof values, "xiaomi-token-plan-cn"> =
flattenModelCatalog("xiaomi-token-plan-cn", values);
@@ -2,19 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/xiaomi-token-plan-sgp.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const XIAOMI_TOKEN_PLAN_SGP_MODELS = values as {
"mimo-v2-pro": Model<"openai-completions"> & {
id: "mimo-v2-pro";
provider: "xiaomi-token-plan-sgp";
};
"mimo-v2.5": Model<"openai-completions"> & {
id: "mimo-v2.5";
provider: "xiaomi-token-plan-sgp";
};
"mimo-v2.5-pro": Model<"openai-completions"> & {
id: "mimo-v2.5-pro";
provider: "xiaomi-token-plan-sgp";
};
};
export const XIAOMI_TOKEN_PLAN_SGP_MODELS: ModelCatalog<typeof values, "xiaomi-token-plan-sgp"> =
flattenModelCatalog("xiaomi-token-plan-sgp", values);
+3 -27
View File
@@ -2,31 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/xiaomi.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const XIAOMI_MODELS = values as {
"mimo-v2-flash": Model<"openai-completions"> & {
id: "mimo-v2-flash";
provider: "xiaomi";
};
"mimo-v2-omni": Model<"openai-completions"> & {
id: "mimo-v2-omni";
provider: "xiaomi";
};
"mimo-v2-pro": Model<"openai-completions"> & {
id: "mimo-v2-pro";
provider: "xiaomi";
};
"mimo-v2.5": Model<"openai-completions"> & {
id: "mimo-v2.5";
provider: "xiaomi";
};
"mimo-v2.5-pro": Model<"openai-completions"> & {
id: "mimo-v2.5-pro";
provider: "xiaomi";
};
"mimo-v2.5-pro-ultraspeed": Model<"openai-completions"> & {
id: "mimo-v2.5-pro-ultraspeed";
provider: "xiaomi";
};
};
export const XIAOMI_MODELS: ModelCatalog<typeof values, "xiaomi"> =
flattenModelCatalog("xiaomi", values);
@@ -2,31 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/zai-coding-cn.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const ZAI_CODING_CN_MODELS = values as {
"glm-4.5-air": Model<"openai-completions"> & {
id: "glm-4.5-air";
provider: "zai-coding-cn";
};
"glm-4.7": Model<"openai-completions"> & {
id: "glm-4.7";
provider: "zai-coding-cn";
};
"glm-5-turbo": Model<"openai-completions"> & {
id: "glm-5-turbo";
provider: "zai-coding-cn";
};
"glm-5.1": Model<"openai-completions"> & {
id: "glm-5.1";
provider: "zai-coding-cn";
};
"glm-5.2": Model<"openai-completions"> & {
id: "glm-5.2";
provider: "zai-coding-cn";
};
"glm-5v-turbo": Model<"openai-completions"> & {
id: "glm-5v-turbo";
provider: "zai-coding-cn";
};
};
export const ZAI_CODING_CN_MODELS: ModelCatalog<typeof values, "zai-coding-cn"> =
flattenModelCatalog("zai-coding-cn", values);
+3 -27
View File
@@ -2,31 +2,7 @@
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/zai.json" with { type: "json" };
import type { Model } from "../types.ts";
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const ZAI_MODELS = values as {
"glm-4.5-air": Model<"openai-completions"> & {
id: "glm-4.5-air";
provider: "zai";
};
"glm-4.7": Model<"openai-completions"> & {
id: "glm-4.7";
provider: "zai";
};
"glm-5-turbo": Model<"openai-completions"> & {
id: "glm-5-turbo";
provider: "zai";
};
"glm-5.1": Model<"openai-completions"> & {
id: "glm-5.1";
provider: "zai";
};
"glm-5.2": Model<"openai-completions"> & {
id: "glm-5.2";
provider: "zai";
};
"glm-5v-turbo": Model<"openai-completions"> & {
id: "glm-5v-turbo";
provider: "zai";
};
};
export const ZAI_MODELS: ModelCatalog<typeof values, "zai"> =
flattenModelCatalog("zai", values);
@@ -0,0 +1,9 @@
import { expectTypeOf, it } from "vitest";
import { XAI_MODELS } from "../src/providers/xai.models.ts";
it("derives model API, ID, and provider literals from grouped model data", () => {
expectTypeOf(XAI_MODELS["grok-4.5"].api).toEqualTypeOf<"openai-responses">();
expectTypeOf(XAI_MODELS["grok-4.5"].id).toEqualTypeOf<"grok-4.5">();
expectTypeOf(XAI_MODELS["grok-4.5"].provider).toEqualTypeOf<"xai">();
expectTypeOf(XAI_MODELS["grok-4.3"].api).toEqualTypeOf<"openai-completions">();
});
+38 -7
View File
@@ -30,14 +30,18 @@ function createFixture(): {
mkdirSync(dataDir, { recursive: true });
writeFileSync(
join(packageRoot, "src", "models.generated.ts"),
'import { TEST_PROVIDER_MODELS } from "./providers/test-provider.models.ts";\n\nexport const MODELS = {\n\t"test-provider": TEST_PROVIDER_MODELS,\n} as const;\n',
'import { TEST_PROVIDER_MODELS } from "./providers/test-provider.models.ts";\n',
);
writeFileSync(
join(providersDir, "test-provider.models.ts"),
'// generated\n\nimport values from "./data/test-provider.json" with { type: "json" };\nimport type { Model } from "../types.ts";\n\nexport const TEST_PROVIDER_MODELS = values as {\n\t"model-a": Model<"openai-completions"> & {\n\t\tid: "model-a";\n\t\tprovider: "test-provider";\n\t};\n};\n',
'import values from "./data/test-provider.json" with { type: "json" };\nimport { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";\n\nexport const TEST_PROVIDER_MODELS: ModelCatalog<typeof values, "test-provider"> =\n\tflattenModelCatalog("test-provider", values);\n',
);
const structure = readModelDataStructure(packageRoot);
const structure: ModelDataStructure = {
"test-provider": {
"model-a": "openai-completions",
},
};
const values: Record<string, unknown> = {
"model-a": {
id: "model-a",
@@ -61,9 +65,10 @@ function writeFixtureData(
structure: ModelDataStructure,
values: Record<string, unknown>,
manifestSchemaVersion = MODEL_DATA_SCHEMA_VERSION,
apiGroup = "openai-completions",
): void {
const filename = "test-provider.json";
const content = `${JSON.stringify(values)}\n`;
const content = `${JSON.stringify({ [apiGroup]: values })}\n`;
writeFileSync(join(dataDir, filename), content);
const manifest = createModelDataManifest(structure, { [filename]: content });
manifest.schemaVersion = manifestSchemaVersion;
@@ -71,8 +76,9 @@ function writeFixtureData(
}
describe("generated model data validation", () => {
it("validates complete data against generated structural catalogs", () => {
const { dataDir, structure } = createFixture();
it("reads and validates API-grouped model data", () => {
const { dataDir, packageRoot, structure } = createFixture();
expect(readModelDataStructure(packageRoot)).toEqual(structure);
expect(() => validateModelDataDirectory(structure, dataDir)).not.toThrow();
});
@@ -94,6 +100,31 @@ describe("generated model data validation", () => {
expect(() => validateModelDataDirectory(fixture.structure, fixture.dataDir)).toThrow(expectedMessage);
});
it("rejects a model in the wrong API group", () => {
const fixture = createFixture();
writeFixtureData(
fixture.dataDir,
fixture.structure,
fixture.values,
MODEL_DATA_SCHEMA_VERSION,
"anthropic-messages",
);
expect(() => validateModelDataDirectory(fixture.structure, fixture.dataDir)).toThrow("grouped under API");
});
it("rejects duplicate model IDs across API groups", () => {
const fixture = createFixture();
const filename = "test-provider.json";
const content = `${JSON.stringify({
"openai-completions": fixture.values,
"anthropic-messages": fixture.values,
})}\n`;
writeFileSync(join(fixture.dataDir, filename), content);
const manifest = createModelDataManifest(fixture.structure, { [filename]: content });
writeFileSync(join(fixture.dataDir, MODEL_DATA_MANIFEST_FILE), `${JSON.stringify(manifest)}\n`);
expect(() => validateModelDataDirectory(fixture.structure, fixture.dataDir)).toThrow("more than one API group");
});
it("rejects missing model IDs and stale file hashes", () => {
const fixture = createFixture();
writeFileSync(join(fixture.dataDir, "test-provider.json"), "{}\n");
@@ -112,7 +143,7 @@ describe("generated model data validation", () => {
expect(() => validateModelDataDirectory(fixture.structure, fixture.dataDir)).toThrow("generation stamp");
});
it("rejects missing provider shards referenced by the aggregator", () => {
it("rejects missing provider shards imported by the aggregator", () => {
const { packageRoot } = createFixture();
writeFileSync(
join(packageRoot, "src", "models.generated.ts"),
+7
View File
@@ -3,6 +3,7 @@ import { InMemoryCredentialStore } from "../src/auth/credential-store.ts";
import { anthropicOAuth } from "../src/auth/oauth/anthropic.ts";
import { githubCopilotOAuth } from "../src/auth/oauth/github-copilot.ts";
import { openaiCodexOAuth } from "../src/auth/oauth/openai-codex.ts";
import { openRouterOAuth } from "../src/auth/oauth/openrouter.ts";
import { xaiOAuth } from "../src/auth/oauth/xai.ts";
import { createModels } from "../src/models.ts";
import * as extensionOAuthCompatibility from "../src/oauth.ts";
@@ -33,6 +34,12 @@ describe.sequential("OAuthAuth adapters", () => {
expect(auth).toEqual({ apiKey: "token" });
});
it("openrouter derives the api key and keeps the permanent credential on refresh", async () => {
const credential = { type: "oauth" as const, access: "token", refresh: "", expires: Number.MAX_SAFE_INTEGER };
expect(await openRouterOAuth.toAuth(credential)).toEqual({ apiKey: "token" });
expect(await openRouterOAuth.refresh(credential)).toBe(credential);
});
it("xAI toAuth derives the api key from the access token", async () => {
const auth = await xaiOAuth.toAuth({ type: "oauth", access: "token", refresh: "r", expires: 0 });
expect(auth).toEqual({ apiKey: "token" });
+231
View File
@@ -0,0 +1,231 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { InMemoryCredentialStore } from "../src/auth/credential-store.ts";
import { openRouterOAuth } from "../src/auth/oauth/openrouter.ts";
import { createImagesModels } from "../src/images-models.ts";
import { createModels } from "../src/models.ts";
import { openrouterProvider } from "../src/providers/openrouter.ts";
import { openrouterImagesProvider } from "../src/providers/openrouter-images.ts";
const TOKEN_URL = "https://openrouter.ai/api/v1/auth/keys";
const nativeFetch = globalThis.fetch;
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
}
function base64url(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
}
describe.sequential("OpenRouter OAuth", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
it("is exposed by both OpenRouter providers alongside API-key auth", () => {
for (const provider of [openrouterProvider(), openrouterImagesProvider()]) {
expect(provider.auth.apiKey).toBeDefined();
expect(provider.auth.oauth).toBeDefined();
expect(provider.auth.oauth?.loginLabel).toBe("Sign in with OpenRouter");
}
});
it("resolves the same stored OAuth key for text and image providers", async () => {
const credentials = new InMemoryCredentialStore();
await credentials.modify("openrouter", async () => ({
type: "oauth",
access: "sk-or-stored",
refresh: "",
expires: Number.MAX_SAFE_INTEGER,
}));
const textModels = createModels({ credentials });
textModels.setProvider(openrouterProvider());
const imageModels = createImagesModels({ credentials });
imageModels.setProvider(openrouterImagesProvider());
expect((await textModels.getAuth("openrouter"))?.auth.apiKey).toBe("sk-or-stored");
expect((await imageModels.getAuth("openrouter"))?.auth.apiKey).toBe("sk-or-stored");
});
it("runs PKCE on a one-shot loopback callback and exchanges the code for a permanent API key", async () => {
let exchangeBody: Record<string, unknown> | undefined;
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = input instanceof Request ? input.url : String(input);
if (url !== TOKEN_URL) return nativeFetch(input, init);
exchangeBody = JSON.parse(String(init?.body)) as Record<string, unknown>;
return jsonResponse({ key: "sk-or-test" });
});
vi.stubGlobal("fetch", fetchMock);
let authorizeUrl: URL | undefined;
let callbackResponse: Promise<Response> | undefined;
const credential = await openRouterOAuth.login({
prompt: async () => {
throw new Error("OpenRouter login must not prompt for a code");
},
notify: (event) => {
if (event.type !== "auth_url") return;
authorizeUrl = new URL(event.url);
const callbackUrl = new URL(authorizeUrl.searchParams.get("callback_url") ?? "");
callbackUrl.searchParams.set("code", "authorization-code");
callbackResponse = nativeFetch(callbackUrl);
},
});
expect(credential).toEqual({
type: "oauth",
access: "sk-or-test",
refresh: "",
expires: Number.MAX_SAFE_INTEGER,
});
expect((await callbackResponse)?.status).toBe(200);
expect(authorizeUrl?.origin).toBe("https://openrouter.ai");
expect(authorizeUrl?.pathname).toBe("/auth");
expect(authorizeUrl?.searchParams.get("code_challenge_method")).toBe("S256");
const callbackUrl = new URL(authorizeUrl?.searchParams.get("callback_url") ?? "");
expect(callbackUrl.hostname).toBe("127.0.0.1");
expect(callbackUrl.pathname).toMatch(/^\/oauth\/callback\/[0-9a-f-]+$/);
expect(exchangeBody).toMatchObject({
code: "authorization-code",
code_challenge_method: "S256",
});
const verifier = exchangeBody?.code_verifier;
expect(typeof verifier).toBe("string");
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(String(verifier)));
expect(authorizeUrl?.searchParams.get("code_challenge")).toBe(base64url(new Uint8Array(digest)));
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("reports token exchange failures through both the callback page and login", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => jsonResponse({ error: { message: "invalid code" } }, 403)),
);
let callbackResponse: Promise<Response> | undefined;
const login = openRouterOAuth.login({
prompt: async () => "",
notify: (event) => {
if (event.type !== "auth_url") return;
const callbackUrl = new URL(new URL(event.url).searchParams.get("callback_url") ?? "");
callbackUrl.searchParams.set("code", "bad-code");
callbackResponse = nativeFetch(callbackUrl);
},
});
await expect(login).rejects.toThrow("OpenRouter OAuth key exchange failed (HTTP 403): invalid code");
expect((await callbackResponse)?.status).toBe(502);
});
it("allows only one token exchange for a callback", async () => {
let completeExchange = (_response: Response): void => {
throw new Error("Token exchange did not start");
};
const fetchMock = vi.fn(
async () =>
new Promise<Response>((resolve) => {
completeExchange = resolve;
}),
);
vi.stubGlobal("fetch", fetchMock);
let callbackUrl: URL | undefined;
let firstCallback: Promise<Response> | undefined;
const login = openRouterOAuth.login({
prompt: async () => "",
notify: (event) => {
if (event.type !== "auth_url") return;
callbackUrl = new URL(new URL(event.url).searchParams.get("callback_url") ?? "");
callbackUrl.searchParams.set("code", "authorization-code");
firstCallback = nativeFetch(callbackUrl);
},
});
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
if (!callbackUrl) throw new Error("OpenRouter did not provide a callback URL");
expect((await nativeFetch(callbackUrl)).status).toBe(409);
expect(fetchMock).toHaveBeenCalledTimes(1);
completeExchange(jsonResponse({ key: "sk-or-test" }));
await expect(login).resolves.toMatchObject({ access: "sk-or-test" });
expect((await firstCallback)?.status).toBe(200);
});
it("rejects a successful response that does not contain a key", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => jsonResponse({ user_id: "user-1" })),
);
let callbackResponse: Promise<Response> | undefined;
const login = openRouterOAuth.login({
prompt: async () => "",
notify: (event) => {
if (event.type !== "auth_url") return;
const callbackUrl = new URL(new URL(event.url).searchParams.get("callback_url") ?? "");
callbackUrl.searchParams.set("code", "code-without-key");
callbackResponse = nativeFetch(callbackUrl);
},
});
await expect(login).rejects.toThrow('OpenRouter OAuth response carries no "key"');
expect((await callbackResponse)?.status).toBe(502);
});
it("closes the pending callback when login is cancelled", async () => {
const controller = new AbortController();
let callbackUrl: URL | undefined;
const login = openRouterOAuth.login({
signal: controller.signal,
prompt: async () => "",
notify: (event) => {
if (event.type !== "auth_url") return;
callbackUrl = new URL(new URL(event.url).searchParams.get("callback_url") ?? "");
controller.abort();
},
});
await expect(login).rejects.toThrow("Login cancelled");
expect(callbackUrl).toBeDefined();
await expect(nativeFetch(callbackUrl!)).rejects.toThrow();
});
it("rejects before opening a callback server when login is already cancelled", async () => {
const controller = new AbortController();
controller.abort();
await expect(
openRouterOAuth.login({
signal: controller.signal,
prompt: async () => "",
notify: () => {
throw new Error("Cancelled login must not emit events");
},
}),
).rejects.toThrow("Login cancelled");
});
it("uses the configured OAuth callback host", async () => {
vi.stubEnv("PI_OAUTH_CALLBACK_HOST", "localhost");
const controller = new AbortController();
let callbackUrl: URL | undefined;
const login = openRouterOAuth.login({
signal: controller.signal,
prompt: async () => "",
notify: (event) => {
if (event.type !== "auth_url") return;
callbackUrl = new URL(new URL(event.url).searchParams.get("callback_url") ?? "");
controller.abort();
},
});
await expect(login).rejects.toThrow("Login cancelled");
expect(callbackUrl?.hostname).toBe("localhost");
});
});
+10
View File
@@ -2,6 +2,15 @@
## [Unreleased]
### Added
- Exposed `PI_SESSION_ID`, `PI_SESSION_FILE`, `PI_PROVIDER`, `PI_MODEL`, and `PI_REASONING_LEVEL` to commands run by built-in and factory-created bash tools.
### Fixed
- Fixed compaction and branch-summary requests to use fresh routing session IDs with prompt caching disabled where supported.
- Fixed explicit self-updates when `PI_SKIP_VERSION_CHECK` is set ([#6977](https://github.com/earendil-works/pi/issues/6977)).
## [0.81.1] - 2026-07-21
### New Features
@@ -57,6 +66,7 @@
- Fixed llama.cpp router download progress updates and removed redundant wording from model action confirmations.
- Moved automatic model catalog network refresh out of startup initialization and into the running interactive and RPC modes.
- Fixed persisted sessions being read and parsed twice when opened, reducing startup latency for large sessions ([#6793](https://github.com/earendil-works/pi/issues/6793)).
- Fixed slow Ctrl+G external-editor startup when the system temporary directory contains many entries ([#6774](https://github.com/earendil-works/pi/issues/6774)).
- Fixed prompt-template defaults for all arguments (`${@:-default}` and `${ARGUMENTS:-default}`) ([#6695](https://github.com/earendil-works/pi/issues/6695)).
- Fixed obsolete custom UI, custom tool, and custom editor examples in the extension documentation ([#6735](https://github.com/earendil-works/pi/issues/6735)).
- Fixed Kimi Coding sessions to show API-equivalent implied costs with the subscription indicator.
+13
View File
@@ -662,6 +662,7 @@ pi --thinking high "Solve this complex problem"
| Variable | Description |
|----------|-------------|
| `PI_CODING_AGENT` | Set to `true` by the CLI and RPC entry points so child processes can detect that they run inside Pi |
| `PI_CODING_AGENT_DIR` | Override config directory (default: `~/.pi/agent`) |
| `PI_CODING_AGENT_SESSION_DIR` | Override session storage directory (overridden by `--session-dir`) |
| `PI_PACKAGE_DIR` | Override package directory (useful for Nix/Guix where store paths tokenize poorly) |
@@ -671,6 +672,18 @@ pi --thinking high "Solve this complex problem"
| `PI_CACHE_RETENTION` | Set to `long` for extended prompt cache (Anthropic: 1h, OpenAI: 24h) |
| `VISUAL`, `EDITOR` | Fallback external editor for Ctrl+G when `externalEditor` is unset; defaults to Notepad on Windows and `nano` elsewhere |
Commands run by the LLM-callable bash tool also receive current session metadata:
| Variable | Description |
|----------|-------------|
| `PI_SESSION_ID` | Current session ID |
| `PI_SESSION_FILE` | Absolute session JSONL path; unset for ephemeral sessions |
| `PI_PROVIDER` | Currently selected model provider |
| `PI_MODEL` | Currently selected model ID |
| `PI_REASONING_LEVEL` | Current effective reasoning level |
These values are resolved when each command starts. See [Environment Variables](docs/environment-variables.md#bash-tool-session-environment) for semantics, examples, and custom-tool opt-out.
---
## Contributing & Development
@@ -0,0 +1,88 @@
# Environment Variables
Pi uses environment variables in three ways:
- Variables such as `PI_OFFLINE` configure the Pi process.
- Pi sets `PI_CODING_AGENT` so child processes can detect that they run inside Pi.
- Commands run by the LLM-callable bash tool receive `PI_*` variables describing the current session.
Provider API-key variables are documented separately in [Providers](providers.md#environment-variables-or-auth-file).
## Process Marker
The CLI and RPC entry points set `PI_CODING_AGENT=true`. Child processes inherit it and can use it to detect that they run inside Pi. It is not session-specific and is not set automatically when Pi is embedded through the SDK.
## Bash Tool Session Environment
Commands run by the bash tool receive the current Pi session state:
| Variable | Description |
|----------|-------------|
| `PI_SESSION_ID` | Current session ID |
| `PI_SESSION_FILE` | Absolute path to the current session JSONL file; unset for ephemeral sessions |
| `PI_PROVIDER` | Currently selected model provider |
| `PI_MODEL` | Currently selected model ID |
| `PI_REASONING_LEVEL` | Current effective reasoning level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max` |
The values are resolved when each command starts. Switching models or changing the reasoning level therefore affects the next bash command without restarting Pi. `PI_PROVIDER` and `PI_MODEL` identify the selected Pi model, not a different upstream model that a router may choose internally.
When asked which model or provider is running, inspect these variables instead of inferring the answer from the system prompt:
```bash
printf '%s/%s\n' "$PI_PROVIDER" "$PI_MODEL"
printf 'reasoning=%s session=%s\n' "$PI_REASONING_LEVEL" "$PI_SESSION_ID"
```
The session file can be inspected directly when the session is persistent:
```bash
if [ -n "$PI_SESSION_FILE" ]; then
tail -n 1 "$PI_SESSION_FILE"
fi
```
These variables are injected into the LLM-callable bash tool. They are not injected into user-entered `!` or `!!` commands.
### Custom Bash Tools
Bash tools created with `createBashTool()` expose the session environment by default when registered with Pi. Injection happens before `spawnHook`, so a hook receives the variables in `ctx.env`:
```typescript
const bashTool = createBashTool(cwd, {
spawnHook: (ctx) => ({
...ctx,
env: { ...ctx.env, CI: "1" },
}),
});
```
Disable session metadata independently of the spawn hook:
```typescript
const bashTool = createBashTool(cwd, {
exposeSessionEnvironment: false,
spawnHook: (ctx) => ctx,
});
```
When disabled, Pi removes inherited values for these variables so nested Pi processes do not expose stale parent-session metadata.
## Pi Process Configuration
These variables are read by Pi itself:
| Variable | Description |
|----------|-------------|
| `PI_CODING_AGENT_DIR` | Override the config directory; default is `~/.pi/agent` |
| `PI_CODING_AGENT_SESSION_DIR` | Override session storage; overridden by `--session-dir` |
| `PI_PACKAGE_DIR` | Override the package directory, useful for Nix/Guix store paths |
| `PI_OFFLINE` | Disable startup network operations, including update checks, package updates, and install/update telemetry |
| `PI_SKIP_VERSION_CHECK` | Disable the `pi.dev` latest-version request |
| `PI_TELEMETRY` | Override install/update telemetry and provider attribution headers: `1`/`true`/`yes` or `0`/`false`/`no` |
| `PI_CACHE_RETENTION` | Set to `long` for extended provider prompt caching where supported |
| `PI_SHARE_VIEWER_URL` | Override the base URL used by `/share` |
| `PI_HARDWARE_CURSOR` | Set to `1` to show the hardware cursor; see [Terminal setup](terminal-setup.md) |
| `VISUAL`, `EDITOR` | External editor fallback when `externalEditor` is unset |
| `HTTP_PROXY`, `HTTPS_PROXY` | Proxy outbound HTTP requests |
Provider credentials such as `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and cloud-provider configuration are listed in [Providers](providers.md#environment-variables-or-auth-file).
+11 -3
View File
@@ -982,9 +982,9 @@ ctx.sessionManager.buildContextEntries() // Active branch entries with compac
ctx.sessionManager.getLeafId() // Current leaf entry ID
```
### ctx.modelRegistry / ctx.model
### ctx.modelRegistry / ctx.model / ctx.thinkingLevel
Access to models, providers, and resolved authentication. `ctx.modelRegistry.getProvider(id)` returns the effective pi-ai provider, while `getProviderAuth(id)` resolves its current API key, headers, base URL, and provider-scoped environment without requiring a loaded model. `ctx.model` is the active model.
Access to models, providers, and resolved authentication. `ctx.modelRegistry.getProvider(id)` returns the effective pi-ai provider, while `getProviderAuth(id)` resolves its current API key, headers, base URL, and provider-scoped environment without requiring a loaded model. `ctx.model` is the active model, and `ctx.thinkingLevel` is its current effective thinking level.
### ctx.signal
@@ -2096,7 +2096,15 @@ const bashTool = createBashTool(cwd, {
});
```
See [examples/extensions/ssh.ts](../examples/extensions/ssh.ts) for a complete SSH example with `--ssh` flag.
`createBashTool()` exposes the current session to commands through `PI_SESSION_ID`, `PI_SESSION_FILE`, `PI_PROVIDER`, `PI_MODEL`, and `PI_REASONING_LEVEL`. Injection happens before `spawnHook`, so hooks receive these values in `env` and preserve them when they spread the existing environment as above. Set `exposeSessionEnvironment: false` to disable them:
```typescript
const bashTool = createBashTool(cwd, {
exposeSessionEnvironment: false,
});
```
See [Bash tool session environment](environment-variables.md#bash-tool-session-environment) for variable semantics. See [examples/extensions/ssh.ts](../examples/extensions/ssh.ts) for a complete SSH example with `--ssh` flag.
### Output Truncation
+1
View File
@@ -68,6 +68,7 @@ For the full first-run flow, see [Quickstart](quickstart.md).
## Reference
- [Environment variables](environment-variables.md) - Pi process configuration and session metadata available to bash tools.
- [Session format](session-format.md) - JSONL session file format, entry types, and SessionManager API.
## Platform setup
+8 -1
View File
@@ -20,9 +20,10 @@ Use `/login` in interactive mode, then select a provider:
- Claude Pro/Max
- GitHub Copilot
- xAI (Grok/X subscription)
- OpenRouter (OAuth-minted API key billed from OpenRouter credits)
- Radius
Use `/logout` to clear credentials. Tokens are stored in `~/.pi/agent/auth.json` and auto-refresh when expired.
Use `/logout` to clear credentials. Tokens are stored in `~/.pi/agent/auth.json` and auto-refresh when expired. OpenRouter instead mints a user-controlled API key that does not expire automatically.
### OpenAI Codex
@@ -43,6 +44,12 @@ Anthropic subscription auth is active for Claude Pro/Max accounts. Third-party h
- Run `/login xai`, then select **Use a subscription**
- `XAI_API_KEY` remains available through **Use an API key**
### OpenRouter
- Run `/login openrouter`, then select **Sign in with OpenRouter** to open the OpenRouter PKCE authorization flow
- The authorization creates a user-controlled OpenRouter API key billed from your OpenRouter credits
- `OPENROUTER_API_KEY` remains available through **Use an API key**
### Radius
Radius is a dynamic `pi-messages` gateway. `/login radius` stores OAuth tokens in `auth.json`; the gateway catalog is refreshed independently and cached in `models-store.json`. Custom Radius gateways can be declared in `models.json` with `"oauth": "radius"` and a gateway `baseUrl`.
-13
View File
@@ -289,19 +289,6 @@ pi --tools read,grep,find,ls -p "Review the code"
pi --exclude-tools ask_question
```
### Environment Variables
| Variable | Description |
|----------|-------------|
| `PI_CODING_AGENT_DIR` | Override config directory; default is `~/.pi/agent` |
| `PI_CODING_AGENT_SESSION_DIR` | Override session storage directory; overridden by `--session-dir` |
| `PI_PACKAGE_DIR` | Override package directory, useful for Nix/Guix store paths |
| `PI_OFFLINE` | Disable startup network operations, including update checks, package update checks, and install/update telemetry |
| `PI_SKIP_VERSION_CHECK` | Skip the Pi version update check at startup. This prevents the `pi.dev` latest-version request |
| `PI_TELEMETRY` | Override install/update telemetry and provider attribution headers: `1`/`true`/`yes` or `0`/`false`/`no`. This does not disable update checks |
| `PI_CACHE_RETENTION` | Set to `long` for extended prompt cache where supported |
| `VISUAL`, `EDITOR` | Fallback external editor for Ctrl+G when `externalEditor` is unset; defaults to Notepad on Windows and `nano` elsewhere |
## Design Principles
Pi keeps the core small and pushes workflow-specific behavior into extensions, skills, prompt templates, and packages.
+1
View File
@@ -455,6 +455,7 @@
"license": "MIT",
"dependencies": {
"@earendil-works/pi-ai": "^0.81.1",
"diff": "8.0.4",
"ignore": "7.0.5",
"typebox": "1.1.38",
"yaml": "2.9.0"
+1
View File
@@ -479,6 +479,7 @@
"license": "MIT",
"dependencies": {
"@earendil-works/pi-ai": "^0.81.1",
"diff": "8.0.4",
"ignore": "7.0.5",
"typebox": "1.1.38",
"yaml": "2.9.0"
@@ -22,7 +22,7 @@ export async function selectConfig(options: ConfigSelectorOptions): Promise<void
initTheme(options.settingsManager.getTheme(), true);
return new Promise((resolve) => {
const ui = new TUI(new ProcessTerminal());
const ui = new TUI(new ProcessTerminal(), undefined, options.agentDir);
let resolved = false;
const selector = new ConfigSelectorComponent(
+1 -1
View File
@@ -79,7 +79,7 @@ export async function createStartupTui(settingsManager: SettingsManager): Promis
const terminalTheme = detectTerminalBackgroundFromEnv().theme;
initTheme(resolveThemeSetting(settingsManager.getThemeSetting(), terminalTheme) ?? terminalTheme);
setKeybindings(KeybindingsManager.create());
const ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor());
const ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor(), getAgentDir());
ui.setClearOnShrink(settingsManager.getClearOnShrink());
return ui;
}
@@ -6,7 +6,7 @@
*/
import type { AgentMessage, StreamFn, ThinkingLevel } from "@earendil-works/pi-agent-core";
import { contentText, type RetryCallbacks, type RetryPolicy, retryAssistantCall } from "@earendil-works/pi-ai";
import { contentText, type RetryCallbacks, type RetryPolicy, retryAssistantCall, uuidv7 } from "@earendil-works/pi-ai";
import type { AssistantMessage, Context, Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai/compat";
import { completeSimple } from "@earendil-works/pi-ai/compat";
import { convertToLlm } from "../messages.ts";
@@ -567,9 +567,17 @@ export async function completeSummarization(
retry?: RetryPolicy,
callbacks?: RetryCallbacks,
): Promise<AssistantMessage> {
// Summaries are standalone requests, so isolate routing and avoid cache writes that cannot be reused.
const requestOptions: SimpleStreamOptions = {
...options,
cacheRetention: "none",
sessionId: uuidv7(),
};
const produce = async (): Promise<AssistantMessage> =>
streamFn ? (await streamFn(model, context, options)).result() : completeSimple(model, context, options);
return retryAssistantCall(produce, retry, options.signal, callbacks);
streamFn
? (await streamFn(model, context, requestOptions)).result()
: completeSimple(model, context, requestOptions);
return retryAssistantCall(produce, retry, requestOptions.signal, callbacks);
}
/**
@@ -694,6 +694,10 @@ export class ExtensionRunner {
runner.assertActive();
return getModel();
},
get thinkingLevel() {
runner.assertActive();
return runner.runtime.getThinkingLevel();
},
isIdle: () => {
runner.assertActive();
return runner.isIdleFn();
@@ -317,6 +317,8 @@ export interface ExtensionContext {
modelRegistry: ModelRegistry;
/** Current model (may be undefined) */
model: Model<any> | undefined;
/** Current thinking level, when provided by the session runtime. */
thinkingLevel?: ThinkingLevel;
/** Whether the agent is idle (not streaming) */
isIdle(): boolean;
/** Whether project-local trust is active for this context. */
@@ -851,7 +851,7 @@ export class SettingsManager {
return this.settings.showCacheMissNotices ?? false;
}
getExternalEditorCommand(): string | undefined {
getExternalEditorCommand(): string {
const configuredEditor = this.settings.externalEditor;
if (typeof configuredEditor === "string" && configuredEditor.trim() !== "") {
return configuredEditor;
@@ -133,7 +133,7 @@ Pi documentation (read only when the user asks about pi itself, its SDK, extensi
- Additional docs: ${docsPath}
- Examples: ${examplesPath} (extensions, custom tools, SDK)
- When reading pi docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory
- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md)
- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md), environment variables (docs/environment-variables.md)
- When working on pi topics, read the docs and examples, and follow .md cross-references before implementing
- Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)`;
+41 -6
View File
@@ -15,7 +15,7 @@ import {
trackDetachedChildPid,
untrackDetachedChildPid,
} from "../../utils/shell.ts";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts";
import type { ExtensionContext, ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts";
import { OutputAccumulator } from "./output-accumulator.ts";
import { getTextOutput, invalidArgText, str } from "./render-utils.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
@@ -155,8 +155,31 @@ export interface BashSpawnContext {
export type BashSpawnHook = (context: BashSpawnContext) => BashSpawnContext;
function resolveSpawnContext(command: string, cwd: string, spawnHook?: BashSpawnHook): BashSpawnContext {
const baseContext: BashSpawnContext = { command, cwd, env: { ...getShellEnv() } };
function resolveSpawnContext(
command: string,
cwd: string,
spawnHook: BashSpawnHook | undefined,
exposeSessionEnvironment: boolean,
ctx: ExtensionContext | undefined,
): BashSpawnContext {
const env = { ...getShellEnv() };
delete env.PI_SESSION_ID;
delete env.PI_SESSION_FILE;
delete env.PI_PROVIDER;
delete env.PI_MODEL;
delete env.PI_REASONING_LEVEL;
if (exposeSessionEnvironment && ctx) {
const model = ctx.model;
env.PI_SESSION_ID = ctx.sessionManager.getSessionId();
const sessionFile = ctx.sessionManager.getSessionFile();
if (sessionFile) env.PI_SESSION_FILE = sessionFile;
if (model) {
env.PI_PROVIDER = model.provider;
env.PI_MODEL = model.id;
}
if (ctx.thinkingLevel) env.PI_REASONING_LEVEL = ctx.thinkingLevel;
}
const baseContext: BashSpawnContext = { command, cwd, env };
return spawnHook ? spawnHook(baseContext) : baseContext;
}
@@ -167,6 +190,8 @@ export interface BashToolOptions {
commandPrefix?: string;
/** Optional explicit shell path from settings */
shellPath?: string;
/** Expose current Pi session metadata as PI_* environment variables. Default: true */
exposeSessionEnvironment?: boolean;
/** Hook to adjust command, cwd, or env before execution */
spawnHook?: BashSpawnHook;
}
@@ -294,22 +319,26 @@ export function createBashToolDefinition(
): ToolDefinition<typeof bashSchema, BashToolDetails | undefined, BashRenderState> {
const ops = options?.operations ?? createLocalBashOperations({ shellPath: options?.shellPath });
const commandPrefix = options?.commandPrefix;
const exposeSessionEnvironment = options?.exposeSessionEnvironment ?? true;
const spawnHook = options?.spawnHook;
return {
name: "bash",
label: "bash",
description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`,
promptSnippet: "Execute bash commands (ls, grep, find, etc.)",
promptGuidelines: exposeSessionEnvironment
? ["Inspect PI_* environment variables for current model and session details."]
: undefined,
parameters: bashSchema,
async execute(
_toolCallId,
{ command, timeout }: { command: string; timeout?: number },
signal?: AbortSignal,
onUpdate?,
_ctx?,
ctx?,
) {
const resolvedCommand = commandPrefix ? `${commandPrefix}\n${command}` : command;
const spawnContext = resolveSpawnContext(resolvedCommand, cwd, spawnHook);
const spawnContext = resolveSpawnContext(resolvedCommand, cwd, spawnHook, exposeSessionEnvironment, ctx);
const output = new OutputAccumulator({ tempFilePrefix: "pi-bash" });
let acceptingOutput = true;
let updateTimer: NodeJS.Timeout | undefined;
@@ -466,5 +495,11 @@ export function createBashToolDefinition(
}
export function createBashTool(cwd: string, options?: BashToolOptions): AgentTool<typeof bashSchema> {
return wrapToolDefinition(createBashToolDefinition(cwd, options));
const definition = createBashToolDefinition(cwd, options);
const tool = wrapToolDefinition(definition);
Object.assign(tool, {
promptSnippet: definition.promptSnippet,
promptGuidelines: definition.promptGuidelines,
});
return tool;
}
@@ -13,8 +13,8 @@ export function wrapToolDefinition<TDetails = unknown>(
parameters: definition.parameters,
prepareArguments: definition.prepareArguments,
executionMode: definition.executionMode,
execute: (toolCallId, params, signal, onUpdate) =>
definition.execute(toolCallId, params, signal, onUpdate, ctxFactory?.() as ExtensionContext),
execute: (toolCallId, params, signal, onUpdate, ctx?: ExtensionContext) =>
definition.execute(toolCallId, params, signal, onUpdate, ctx ?? (ctxFactory?.() as ExtensionContext)),
};
}
@@ -3,10 +3,6 @@
* Supports Ctrl+G for external editor.
*/
import { spawn } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
Container,
Editor,
@@ -18,6 +14,7 @@ import {
type TUI,
} from "@earendil-works/pi-tui";
import type { KeybindingsManager } from "../../../core/keybindings.ts";
import { editInExternalEditor } from "../external-editor.ts";
import { getEditorTheme, theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyHint } from "./keybinding-hints.ts";
@@ -28,7 +25,7 @@ export class ExtensionEditorComponent extends Container implements Focusable {
private onCancelCallback: () => void;
private tui: TUI;
private keybindings: KeybindingsManager;
private externalEditorCommand: string | undefined;
private externalEditorCommand: string;
private _focused = false;
get focused(): boolean {
@@ -53,7 +50,11 @@ export class ExtensionEditorComponent extends Container implements Focusable {
this.tui = tui;
this.keybindings = keybindings;
this.externalEditorCommand = externalEditorCommand;
this.externalEditorCommand =
externalEditorCommand ||
process.env.VISUAL ||
process.env.EDITOR ||
(process.platform === "win32" ? "notepad" : "nano");
this.onSubmitCallback = onSubmit;
this.onCancelCallback = onCancel;
@@ -79,14 +80,13 @@ export class ExtensionEditorComponent extends Container implements Focusable {
this.addChild(new Spacer(1));
// Add hint
const hasExternalEditor = !!this.getExternalEditorCommand();
const hint =
keyHint("tui.select.confirm", "submit") +
" " +
keyHint("tui.input.newLine", "newline") +
" " +
keyHint("tui.select.cancel", "cancel") +
(hasExternalEditor ? ` ${keyHint("app.editor.external", "external editor")}` : "");
` ${keyHint("app.editor.external", "external editor")}`;
this.addChild(new Text(hint, 1, 0));
this.addChild(new Spacer(1));
@@ -105,7 +105,7 @@ export class ExtensionEditorComponent extends Container implements Focusable {
// External editor (app keybinding)
if (this.keybindings.matches(keyData, "app.editor.external")) {
this.openExternalEditor();
void this.handleOpenExternalEditor();
return;
}
@@ -113,54 +113,19 @@ export class ExtensionEditorComponent extends Container implements Focusable {
this.editor.handleInput(keyData);
}
private getExternalEditorCommand(): string | undefined {
const editorCmd = this.externalEditorCommand || process.env.VISUAL || process.env.EDITOR;
if (editorCmd) {
return editorCmd;
}
return process.platform === "win32" ? "notepad" : "nano";
}
private async openExternalEditor(): Promise<void> {
const editorCmd = this.getExternalEditorCommand();
if (!editorCmd) {
return;
}
const currentText = this.editor.getText();
const tmpFile = path.join(os.tmpdir(), `pi-extension-editor-${Date.now()}.md`);
private async handleOpenExternalEditor(): Promise<void> {
const content = this.editor.getText();
this.tui.stop();
try {
fs.writeFileSync(tmpFile, currentText, "utf-8");
this.tui.stop();
const [editor, ...editorArgs] = editorCmd.split(" ");
process.stdout.write(`Launching external editor: ${editorCmd}\nPi will resume when the editor exits.\n`);
// Do not use spawnSync here. On Windows, synchronous child_process calls can keep
// Node/libuv's console input read active after tui.stop() pauses stdin, racing
// vim/nvim for the console input buffer until Ctrl+C cancels the pending read.
const status = await new Promise<number | null>((resolve) => {
const child = spawn(editor, [...editorArgs, tmpFile], {
stdio: "inherit",
shell: process.platform === "win32",
});
child.on("error", () => resolve(null));
child.on("close", (code) => resolve(code));
const result = await editInExternalEditor({
command: this.externalEditorCommand,
content,
});
if (status === 0) {
const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, "");
this.editor.setText(newContent);
if (result.status === "complete") {
this.editor.setText(result.content);
}
} finally {
try {
fs.unlinkSync(tmpFile);
} catch {
// Ignore cleanup errors
}
this.tui.start();
// Force full re-render since external editor uses alternate screen
this.tui.requestRender(true);
}
}
@@ -0,0 +1,45 @@
import { spawn } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
export interface ExternalEditorOptions {
command: string;
content: string;
}
export type ExternalEditorResult = { status: "complete"; content: string } | { status: "failed" };
export async function editInExternalEditor(options: ExternalEditorOptions): Promise<ExternalEditorResult> {
const directory = mkdtempSync(join(tmpdir(), "pi-editor-"));
const filePath = join(directory, "prompt.md");
try {
writeFileSync(filePath, options.content, "utf-8");
const [editor, ...editorArgs] = options.command.split(" ");
process.stdout.write(`Launching external editor: ${options.command}\nPi will resume when the editor exits.\n`);
// Do not use spawnSync here. On Windows, synchronous child_process calls can keep
// Node/libuv's console input read active after the parent pauses stdin, racing
// vim/nvim for the console input buffer until Ctrl+C cancels the pending read.
const exitCode = await new Promise<number | null>((resolve) => {
const child = spawn(editor, [...editorArgs, filePath], {
stdio: "inherit",
shell: process.platform === "win32",
});
child.on("error", () => resolve(null));
child.on("close", (code) => resolve(code));
});
if (exitCode !== 0) {
return { status: "failed" };
}
return { status: "complete", content: readFileSync(filePath, "utf-8").replace(/\n$/, "") };
} finally {
try {
rmSync(directory, { recursive: true, force: true });
} catch {
// Cleanup is best effort.
}
}
}
@@ -137,6 +137,7 @@ import { TreeSelectorComponent } from "./components/tree-selector.ts";
import { TrustSelectorComponent } from "./components/trust-selector.ts";
import { UserMessageComponent } from "./components/user-message.ts";
import { UserMessageSelectorComponent } from "./components/user-message-selector.ts";
import { editInExternalEditor } from "./external-editor.ts";
import { getModelSearchText } from "./model-search.ts";
import {
getAvailableThemes,
@@ -450,7 +451,7 @@ export class InteractiveMode {
await this.rebindCurrentSession({ renderBeforeBind: true });
});
this.version = VERSION;
this.ui = new TUI(new ProcessTerminal(), this.settingsManager.getShowHardwareCursor());
this.ui = new TUI(new ProcessTerminal(), this.settingsManager.getShowHardwareCursor(), getAgentDir());
this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
this.headerContainer = new Container();
this.loadedResourcesContainer = new Container();
@@ -1076,8 +1077,16 @@ export class InteractiveMode {
* Get a short path relative to the package root for display.
*/
private getShortPath(fullPath: string, sourceInfo?: SourceInfo): string {
const normalizedFullPath = fullPath.replace(/\\/g, "/");
const baseDir = sourceInfo?.baseDir;
if (baseDir && this.isPackageSource(sourceInfo)) {
const normalizedBaseDir = baseDir.replace(/\\/g, "/");
const npmRootMatch = normalizedBaseDir.match(/^(.*\/node_modules)\/(@?[^/]+(?:\/[^/]+)?)$/);
// If fullPath is under the same node_modules root as baseDir, preserve that relative topology.
if (npmRootMatch?.[1] && normalizedFullPath.startsWith(`${npmRootMatch[1]}/`)) {
return path.posix.relative(normalizedBaseDir, normalizedFullPath);
}
const relativePath = path.relative(path.resolve(baseDir), path.resolve(fullPath));
if (
relativePath &&
@@ -1091,12 +1100,12 @@ export class InteractiveMode {
}
const source = sourceInfo?.source ?? "";
const npmMatch = fullPath.match(/node_modules\/(@?[^/]+(?:\/[^/]+)?)\/(.*)/);
const npmMatch = normalizedFullPath.match(/node_modules\/(@?[^/]+(?:\/[^/]+)?)\/(.*)/);
if (npmMatch && source.startsWith("npm:")) {
return npmMatch[2];
}
const gitMatch = fullPath.match(/git\/[^/]+\/[^/]+\/(.*)/);
const gitMatch = normalizedFullPath.match(/git\/[^/]+\/[^/]+\/(.*)/);
if (gitMatch && source.startsWith("git:")) {
return gitMatch[1];
}
@@ -1774,6 +1783,7 @@ export class InteractiveMode {
sessionManager: this.sessionManager,
modelRegistry: extensionRunner.getModelRegistry(),
model: this.session.model,
thinkingLevel: this.session.thinkingLevel,
isIdle: () => this.session.isIdle,
isProjectTrusted: () => this.settingsManager.isProjectTrusted(),
signal: this.session.agent.signal,
@@ -2577,7 +2587,7 @@ export class InteractiveMode {
this.defaultEditor.onAction("app.model.select", () => this.showModelSelector());
this.defaultEditor.onAction("app.tools.expand", () => this.toggleToolOutputExpansion());
this.defaultEditor.onAction("app.thinking.toggle", () => this.toggleThinkingBlockVisibility());
this.defaultEditor.onAction("app.editor.external", () => this.openExternalEditor());
this.defaultEditor.onAction("app.editor.external", () => void this.handleOpenExternalEditor());
this.defaultEditor.onAction("app.message.copy", () => void this.handleCopyCommand());
this.defaultEditor.onAction("app.message.followUp", () => this.handleFollowUp());
this.defaultEditor.onAction("app.message.dequeue", () => this.handleDequeue());
@@ -3809,57 +3819,20 @@ export class InteractiveMode {
this.showStatus(`Thinking blocks: ${this.hideThinkingBlock ? "hidden" : "visible"}`);
}
private async openExternalEditor(): Promise<void> {
private async handleOpenExternalEditor(): Promise<void> {
const editorCmd = this.settingsManager.getExternalEditorCommand();
if (!editorCmd) {
this.showWarning("No editor configured. Set externalEditor in settings.json or $VISUAL/$EDITOR.");
return;
}
const currentText = this.editor.getExpandedText?.() ?? this.editor.getText();
const tmpFile = path.join(os.tmpdir(), `pi-editor-${Date.now()}.pi.md`);
const content = this.editor.getExpandedText?.() ?? this.editor.getText();
this.ui.stop();
try {
// Write current content to temp file
fs.writeFileSync(tmpFile, currentText, "utf-8");
// Stop TUI to release terminal
this.ui.stop();
// Split by space to support editor arguments (e.g., "code --wait")
const [editor, ...editorArgs] = editorCmd.split(" ");
process.stdout.write(`Launching external editor: ${editorCmd}\nPi will resume when the editor exits.\n`);
// Do not use spawnSync here. On Windows, synchronous child_process calls can keep
// Node/libuv's console input read active after ui.stop() pauses stdin, racing
// vim/nvim for the console input buffer until Ctrl+C cancels the pending read.
const status = await new Promise<number | null>((resolve) => {
const child = spawn(editor, [...editorArgs, tmpFile], {
stdio: "inherit",
shell: process.platform === "win32",
});
child.on("error", () => resolve(null));
child.on("close", (code) => resolve(code));
const result = await editInExternalEditor({
command: editorCmd,
content,
});
// On successful exit (status 0), replace editor content
if (status === 0) {
const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, "");
this.editor.setText(newContent);
if (result.status === "complete") {
this.editor.setText(result.content);
}
// On non-zero exit, keep original text (no action needed)
} finally {
// Clean up temp file
try {
fs.unlinkSync(tmpFile);
} catch {
// Ignore cleanup errors
}
// Restart TUI
this.ui.start();
// Force full re-render since external editor uses alternate screen
this.ui.requestRender(true);
}
}
@@ -31,7 +31,7 @@ export async function getLatestPiRelease(
currentVersion: string,
options: { timeoutMs?: number } = {},
): Promise<LatestPiRelease | undefined> {
if (process.env.PI_SKIP_VERSION_CHECK || process.env.PI_OFFLINE) return undefined;
if (process.env.PI_OFFLINE) return undefined;
const response = await fetch(LATEST_VERSION_URL, {
headers: {
@@ -68,6 +68,8 @@ export async function getLatestPiVersion(
}
export async function checkForNewPiVersion(currentVersion: string): Promise<LatestPiRelease | undefined> {
if (process.env.PI_SKIP_VERSION_CHECK) return undefined;
try {
const latestRelease = await getLatestPiRelease(currentVersion);
if (latestRelease && isNewerPackageVersion(latestRelease.version, currentVersion)) {

Some files were not shown because too many files have changed in this diff Show More