Changelog, dev startup, README, AgentHub 0.4.0, model catalog, and landing site (#7)

Branch-length batch covering tooling, the model layer, the Web App and the public
surfaces. Highlights:

- Changelog: a per-release `changelog/<version>/` tree, grouped by the surface each
  change touches, with a root CHANGELOG.md holding one line per release.
- Dev startup: `scripts/dev-prebuild.mjs` serializes the skills+core prebuild behind a
  lock and keeps `pnpm install` current; `pnpm dev` runs server+web together.
- AgentHub 0.3.3 -> 0.4.0: OmniMessage complete payloads carry one opaque `fidelity`
  object in place of item-level `signature`/`phase`, threaded verbatim through Trace,
  replay and resume; malformed classification adapted to the new error types.
- Model layer: a model is always referenced by an explicit `(provider, model_id)` pair.
  The provider is never inferred, guessed or defaulted -- both the catalog inference and
  the unique-match config resolution are gone, and CLI, SDK, server routes and
  run_subagent all require the complete pair. Catalog gains the Qwen Token Plan, Qwen
  Pay-As-You-Go and Fireworks AI gateways, plus an expanded OpenRouter group.
- Web App: catalog preset sync and per-group speed test on the Models page, positional
  slash commands, a markdown renderer, skill-library update reminders, and a vertically
  centred draft page whose upward menus size themselves to the room available.
- Public surfaces: restructured READMEs, the penguin.ooo landing site and blog, refreshed
  benchmark results for both suites, and the demo videos playing on the landing page.

Includes the fixes from a full review of the branch: 23 confirmed findings, among them a
provider-inference bug that could send one vendor's API key to another vendor's endpoint,
and an Escape handler that destroyed the composer's contents unrecoverably.

Verified on the branch head: pnpm test (1127 passing, 7 packages), pnpm typecheck and
pnpm format:check clean, Playwright e2e 14/14.
This commit is contained in:
Yaowei Zheng
2026-07-21 17:43:31 +08:00
committed by GitHub
parent abf0a2f248
commit d4faee3a1e
214 changed files with 9406 additions and 1688 deletions
+28 -16
View File
@@ -79,12 +79,14 @@ export interface CreateAgentOptions {
export interface CreateSessionOptions {
/** Workspace for this run; if unspecified, a temporary Workspace is created under the Agent directory. */
workspaceDir?: string;
/** Model used for this Session (upstream model_id); if unspecified, uses the Project's default Model. */
/**
* Model used for this Session (upstream model_id); must be given together with `provider`.
* Omit both to use the Project's default Model.
*/
modelId?: string;
/**
* Provider grouping for `modelId` (a paired reference); if omitted, resolved via
* `resolveModelRef` semantics — `model_id` only resolves if it is a globally unique
* exact match in the config; zero or multiple matches produce a clear error.
* Provider grouping for `modelId`: the two together form the paired reference. It is never
* inferred, so it's "both or neither" — either half alone is an error, not a lookup.
*/
provider?: string;
/** Explicit credentials; if unspecified, falls back to credentials in the Project config, then to AgentHub reading environment variables. */
@@ -134,18 +136,20 @@ export class Agent {
*/
async createSession(opts: CreateSessionOptions = {}): Promise<Session> {
// Model is validated first (before creating the Workspace, so failure leaves no
// temp directory behind): the reference must resolve to an entry in the Project
// config (the (provider, model_id) pair is the unique key); a reference
// outside the config throws immediately rather than passing silently — otherwise
// credentials, pricing, and the context window would all be unavailable.
if (opts.modelId === undefined && opts.provider !== undefined) {
// temp directory behind): the reference must be the complete (provider, model_id)
// pair — the config's unique key — and must name an entry in the Project config; a
// reference outside the config throws immediately rather than passing silently,
// otherwise credentials, pricing, and the context window would all be unavailable.
// Half a reference is always an error: the missing half is never inferred, since a
// guessed provider would send the entry's credential to a vendor nobody named.
if ((opts.modelId === undefined) !== (opts.provider === undefined)) {
throw new Error(
"provider was specified without modelId: a model reference must be given as a pair (provider cannot be used alone).",
"A model reference must be given as a (provider, model_id) pair: both must be specified, or neither (to use the Project's default model).",
);
}
let ref: ModelRef;
if (opts.modelId !== undefined) {
// The only entry point for resolving an "omitted provider" reference (resolveModelRef): three branches — unique match / zero matches / ambiguous.
if (opts.modelId !== undefined && opts.provider !== undefined) {
// Pair validation only (resolveModelRef): the pair either names a configured entry or errors.
ref = resolveModelRef(this.projectConfig, opts.modelId, opts.provider);
} else if (this.projectConfig.default_model) {
ref = this.projectConfig.default_model;
@@ -211,6 +215,8 @@ export class Agent {
sessionEnvironment(workspaceDir, sessionId, {
agentId: this.state.agentId,
projectDir: projectDir(this.state.root, this.state.projectId),
provider: modelEntry.provider,
modelId: modelEntry.model_id,
}),
Object.keys(vault),
installedSkills,
@@ -445,9 +451,12 @@ export class Agent {
const { workspaceDir, modelEntry, apiKey, baseUrl, systemPrompt, subagentDepth, vault } = args;
// Child-Agent runner: injected into the run_subagent tool so it doesn't need to
// depend on Agent/Session (breaking a circular dependency). The model can
// optionally choose agentId (omitted = call the current Agent) and modelId
// (omitted = Project default). Precheck errors (depth limit exceeded / agent
// doesn't exist) are expressed as throws, which the Environment collapses to failed.
// optionally choose agentId (omitted = call the current Agent) and the child
// Session's model (omitted = Project default); the model reference is forwarded to
// createSession as-is and must be a complete (provider, model_id) pair — half a
// reference is rejected there rather than being guessed here. Precheck errors (depth
// limit exceeded / agent doesn't exist) are expressed as throws, which the
// Environment collapses to failed.
// Docs: /docs/interfaces § "Subagent interfaces"
const parentAgent = this;
const { root, projectId, agentId: parentAgentId } = this.state;
@@ -455,7 +464,7 @@ export class Agent {
// Spawn and run are separate: the same child Session can run for multiple turns
// (continuing via input_subagent appending a prompt); resource cleanup is
// consolidated in handle.dispose (called by the managing ManagedSubagentSession).
async spawn({ agentId, modelId }) {
async spawn({ agentId, modelId, provider }) {
if (subagentDepth >= MAX_SUBAGENT_DEPTH) {
throw new Error(
`subagent depth limit ${MAX_SUBAGENT_DEPTH} reached; not spawning another subagent`,
@@ -475,9 +484,12 @@ export class Agent {
agentId !== undefined && agentId !== parentAgentId
? await createAgent({ root, projectId, agentId })
: parentAgent;
// The model reference is forwarded as a whole: createSession rejects half a pair, so a
// caller that named only one side gets the same error the CLI and HTTP layers give.
const childSession = await childAgent.createSession({
workspaceDir,
...(modelId !== undefined ? { modelId } : {}),
...(provider !== undefined ? { provider } : {}),
subagentDepth: subagentDepth + 1,
});
// All child-session messages are tagged with an origin (the child Session id,
@@ -82,6 +82,15 @@ export function createSubagentTool(
}
const agentId = typeof args.agent_id === "string" ? args.agent_id : undefined;
const modelId = typeof args.model_id === "string" ? args.model_id : undefined;
const provider = typeof args.provider === "string" ? args.provider : undefined;
// A model is referenced by the complete (provider, model_id) pair — never half of one.
// Caught here rather than in createSession so the model is told which half it left out.
if ((modelId === undefined) !== (provider === undefined)) {
yield* fail(
"[run_subagent error: `model_id` and `provider` must be given together (a model reference is the pair), or both omitted to use the Project default model]",
);
return { stopReason: "failed" };
}
const yieldMs = clampYield(
args.yield_time_ms,
DEFAULT_SUBAGENT_YIELD_MS,
@@ -106,6 +115,7 @@ export function createSubagentTool(
const handle = await runner.spawn({
...(agentId !== undefined ? { agentId } : {}),
...(modelId !== undefined ? { modelId } : {}),
...(provider !== undefined ? { provider } : {}),
});
session = new ManagedSubagentSession(handle);
} catch (err) {
+8 -2
View File
@@ -9,7 +9,8 @@
*
* ```ts
* const agent = await createAgent({ agentId: "default_agent" });
* const session = await agent.createSession({ workspaceDir, modelId });
* // A model reference is always the (provider, model_id) pair; omit both for the Project default.
* const session = await agent.createSession({ workspaceDir, provider, modelId });
* for await (const output of session.run([userText("...")])) { ... }
* ```
*/
@@ -36,7 +37,12 @@ export type {
} from "./engine/context-engine.js";
export { Session } from "./session.js";
export type { SessionConfig } from "./session.js";
export { buildTitlePrompt, generateTitleWithLLM, sanitizeTitle } from "./session-title.js";
export {
buildTitlePrompt,
generateTitleWithLLM,
sanitizeTitle,
stripConversationMarkers,
} from "./session-title.js";
export type { SessionTitleResult } from "./session-title.js";
export { Agent, createAgent } from "./agent.js";
export type { CreateAgentOptions, CreateSessionOptions, ResumeSessionOptions } from "./agent.js";
+7 -1
View File
@@ -200,8 +200,14 @@ export interface SubagentRunner {
spawn(input: {
/** The child Agent's agentId; if omitted, reuses the current Agent (self-invocation). */
agentId?: string;
/** The Model used by the child Session; if omitted, uses the Project's default Model. */
/**
* Upstream model id for the child Session, paired with `provider` — a model reference is
* always the complete pair. Omit both to use the Project's default Model; supplying one
* half without the other is rejected.
*/
modelId?: string;
/** Provider group for `modelId`; required whenever `modelId` is given. */
provider?: string;
}): Promise<SubagentHandle>;
}
@@ -35,7 +35,7 @@ export function formatSessionId(date: Date = new Date()): string {
export function sessionEnvironment(
workspaceDir: string,
sessionId: string,
ids: { agentId: string; projectDir: string },
ids: { agentId: string; projectDir: string; provider: string; modelId: string },
date = new Date(),
): SessionEnvironment {
return {
@@ -43,6 +43,8 @@ export function sessionEnvironment(
cwd: workspaceDir,
agentId: ids.agentId,
projectDir: ids.projectDir,
provider: ids.provider,
modelId: ids.modelId,
platform: process.platform,
osVersion: getOsVersion(),
date: formatLocalDate(date),
+120 -65
View File
@@ -21,7 +21,12 @@
* `context_engine` only consumes OmniMessage; all Uni* protocol details are encapsulated here.
* Docs: /docs/interfaces § "The built-in implementation: GenerativeModel".
*/
import { AutoLLMClient, ThinkingLevel } from "@prismshadow/agenthub";
import {
AutoLLMClient,
EmptyResponseError,
ThinkingLevel,
ToolCallArgumentParseError,
} from "@prismshadow/agenthub";
import type {
ContentItem,
FinishReason,
@@ -45,6 +50,7 @@ import {
} from "../omnimessage/index.js";
import type {
CompleteModelPayload,
Fidelity,
OmniMessage,
StopReason,
TokenCounts,
@@ -75,21 +81,43 @@ function parseToolArguments(raw: string): Record<string, unknown> {
return parsed !== null && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
}
/**
* Whether a fidelity payload carries at least one key (mirrors AgentHub's baseClient helper —
* an absent and an empty fidelity are equivalent).
*/
function hasFidelity(fidelity?: Fidelity): boolean {
return fidelity != null && Object.keys(fidelity).length > 0;
}
/**
* Compare two fidelity payloads by value (mirrors AgentHub's baseClient helper). Fidelity
* objects are built with a stable key order by each AgentHub client, so JSON serialization is
* a faithful equality check.
*/
function fidelityEquals(a?: Fidelity, b?: Fidelity): boolean {
return JSON.stringify(a ?? {}) === JSON.stringify(b ?? {});
}
/** Spread helper: attach `fidelity` only when it carries at least one key. */
function fidelityProp(fidelity?: Fidelity): { fidelity?: Fidelity } {
return hasFidelity(fidelity) ? { fidelity } : {};
}
/**
* Maps a complete OmniMessage payload to an AgentHub `ContentItem`.
* Only complete model_msg payloads are supported; `partial_*` is an output-only protocol.
*/
function payloadToContentItem(payload: CompleteModelPayload): ContentItem {
// Provider-fidelity fields (signature / phase) are restored verbatim — some models require
// them when history is replayed back (e.g. Claude thinking signatures, GPT-5 encrypted
// reasoning and phase segmentation); losing them would break Session recovery.
// The provider-fidelity payload is opaque and restored verbatim — some models require it
// when history is replayed back (e.g. Claude thinking signatures, GPT-5 encrypted reasoning
// and phase segmentation, the OpenAI-compatible reasoning field name); losing it would break
// Session recovery.
switch (payload.type) {
case "text":
return {
type: "text",
text: payload.text,
...(payload.phase != null ? { phase: payload.phase } : {}),
...(payload.signature !== undefined ? { signature: payload.signature } : {}),
...fidelityProp(payload.fidelity),
};
case "image_url":
return { type: "image_url", image_url: payload.image_url };
@@ -98,20 +126,20 @@ function payloadToContentItem(payload: CompleteModelPayload): ContentItem {
type: "inline_data",
data: Buffer.from(payload.data, "base64"),
mime_type: payload.mime_type,
...(payload.signature !== undefined ? { signature: payload.signature } : {}),
...fidelityProp(payload.fidelity),
};
case "inline_thinking":
return {
type: "inline_thinking",
data: Buffer.from(payload.data, "base64"),
mime_type: payload.mime_type,
...(payload.signature !== undefined ? { signature: payload.signature } : {}),
...fidelityProp(payload.fidelity),
};
case "thinking":
return {
type: "thinking",
thinking: payload.thinking,
...(payload.signature !== undefined ? { signature: payload.signature } : {}),
...fidelityProp(payload.fidelity),
};
case "tool_call":
return {
@@ -121,7 +149,7 @@ function payloadToContentItem(payload: CompleteModelPayload): ContentItem {
arguments: parseToolArguments(payload.arguments),
// On the way back, strip the uniqueness suffix to restore the provider's original id (see tool-call-ids.ts).
tool_call_id: stripToolCallIdSuffix(payload.tool_call_id),
...(payload.signature !== undefined ? { signature: payload.signature } : {}),
...fidelityProp(payload.fidelity),
};
case "tool_call_output":
return {
@@ -240,8 +268,8 @@ interface ToolCallAccumulator {
toolCallId: string;
/** The original tool_call_id reported by the provider (the attribution key for inbound events). */
providerKey: string;
/** Provider-fidelity field: signature (kept verbatim, produced alongside the complete tool_call). */
signature: string | undefined;
/** Provider-fidelity payload (kept verbatim, produced alongside the complete tool_call). */
fidelity: Fidelity | undefined;
/** Whether this tool_call's complete message has already been emitted eagerly in `pushEvent` (avoids duplicate emission in finish). */
emitted: boolean;
}
@@ -274,12 +302,12 @@ export class EventTranslator {
// Buffers needed for the complete message.
private textBuffer = "";
private thinkingBuffer = "";
// Provider-fidelity fields: the thinking block's signature (a signature
// marks the end of a block) and the current text segment's phase (sticky across segments;
// a differing phase marker starts a new segment) and signature.
private thinkingSignature: string | undefined;
private textPhase: string | null = null;
private textSignature: string | undefined;
// Provider-fidelity payloads of the currently open segments. Segmentation mirrors AgentHub's
// baseClient aggregation: a thinking block is closed by its fidelity payload (a run of equal
// fidelity is one block); a text segment is closed by a `fidelity.signature` and split by a
// differing `fidelity.phase`.
private thinkingFidelity: Fidelity | undefined;
private textFidelity: Fidelity | undefined;
/** Provider id keys saved in order of appearance, so complete tool_calls are emitted in a stable order. */
private toolOrder: string[] = [];
/** provider's original tool_call_id → the accumulator for the **latest** call under that id. */
@@ -306,17 +334,26 @@ export class EventTranslator {
for (const item of event.content_items) {
switch (item.type) {
case "text": {
// Provider fidelity: a phase marker can arrive as an increment with **empty text**
// (e.g. GPT-5's segment markers). A phase differing from the current segment's phase
// starts a new segment — providers split by phase when replaying history, so mixing
// segments would break fidelity. Phase is sticky across segments (a subsequent segment
// with the same phase isn't re-marked).
if (item.phase != null && item.phase !== this.textPhase) {
// Mirrors AgentHub baseClient text aggregation. A `fidelity.phase` marker can arrive
// as an increment with **empty text** (e.g. GPT-5's segment markers): a phase
// differing from the current segment's phase starts a new segment — providers split
// by phase when replaying history, so mixing segments would break fidelity. A
// `fidelity.signature` closes the segment: any further content starts a new one.
// On merge, fidelity keys accumulate ({...current, ...incoming}).
const curPhase = (this.textFidelity as { phase?: unknown } | undefined)?.phase ?? null;
const inPhase = (item.fidelity as { phase?: unknown } | undefined)?.phase ?? null;
const signatureClosed =
(this.textFidelity as { signature?: unknown } | undefined)?.signature != null;
if (
(signatureClosed && (item.text || hasFidelity(item.fidelity))) ||
(inPhase != null && inPhase !== curPhase)
) {
yield* this.flushThinking("completed");
yield* this.flushText("completed");
this.textPhase = item.phase;
}
if (item.signature) this.textSignature = item.signature;
if (hasFidelity(item.fidelity)) {
this.textFidelity = { ...this.textFidelity, ...item.fidelity };
}
if (!item.text) break;
// Type boundary: before a text segment starts, flush any unclosed thinking
// segment, so the complete-message order matches generation order (thinking → text).
@@ -332,16 +369,22 @@ export class EventTranslator {
break;
}
case "thinking": {
// Provider fidelity: a thinking block ends with a signature (Claude's signature_delta
// is empty text + signature; redacted blocks carry sentinel text + signature; GPT-5
// encrypted reasoning is empty text + signature). If thinking content/a new signature
// arrives after a signature is already set, that's a new block — close the current
// segment first, so each block's signature stays independently faithful and blocks
// don't bleed into each other when history is replayed.
if (this.thinkingSignature !== undefined && (item.thinking || item.signature)) {
// Mirrors AgentHub baseClient thinking aggregation: a thinking block is closed by its
// fidelity payload (Claude's signature_delta is empty text + fidelity{signature};
// redacted blocks carry sentinel text + fidelity; GPT-5 encrypted reasoning is empty
// text + fidelity{id, encrypted_content}), and **a run of equal fidelity is one
// block** — OpenAI-compatible clients stamp every delta with the same
// fidelity{reasoning_field}, which must not split blocks. Content arriving after a
// different fidelity is set starts a new block, so each block's fidelity stays
// independently faithful when history is replayed.
if (
hasFidelity(this.thinkingFidelity) &&
!fidelityEquals(this.thinkingFidelity, item.fidelity) &&
(item.thinking || hasFidelity(item.fidelity))
) {
yield* this.flushThinking("completed");
}
if (item.signature) this.thinkingSignature = item.signature;
if (hasFidelity(item.fidelity)) this.thinkingFidelity = item.fidelity;
if (!item.thinking) break;
// Type boundary: before a thinking segment starts, flush any unclosed text
// segment, so the complete-message order matches generation order (text → thinking).
@@ -365,7 +408,7 @@ export class EventTranslator {
if (item.tool_call_id) this.activeToolCallId = item.tool_call_id;
const acc = this.ensureTool(providerKey, item.name);
if (item.name) acc.name = item.name;
if (item.signature) acc.signature = item.signature;
if (hasFidelity(item.fidelity)) acc.fidelity = item.fidelity;
// Externally always use the uniqueness-resolved id (with a `#n` suffix on provider id collisions), matching the complete tool_call.
if (!this.toolStarted.has(acc.toolCallId)) {
// Type boundary: before a new tool_call starts, flush any unclosed thinking/text
@@ -418,7 +461,7 @@ export class EventTranslator {
if (!acc || acc.emitted) acc = this.createTool(item.tool_call_id, item.name);
acc.name = item.name;
acc.completeArgs = JSON.stringify(item.arguments ?? {});
if (item.signature) acc.signature = item.signature;
if (hasFidelity(item.fidelity)) acc.fidelity = item.fidelity;
yield* this.emitCompleteTool(acc);
break;
}
@@ -507,7 +550,7 @@ export class EventTranslator {
arguments: acc.completeArgs ?? acc.argsBuffer,
toolCallId: acc.toolCallId,
stopReason,
...(acc.signature !== undefined ? { signature: acc.signature } : {}),
...(acc.fidelity !== undefined ? { fidelity: acc.fidelity } : {}),
});
// activeToolCallId holds the provider id key (used to attribute id-less deltas); reset it by providerKey.
if (this.activeToolCallId === acc.providerKey) {
@@ -536,14 +579,12 @@ export class EventTranslator {
yield partialThinking("stop", "", stopReason);
this.thinkingStarted = false;
}
// A thinking block with empty text but a signature (GPT-5 encrypted reasoning) still
// produces a complete message — the signature is required when replaying history.
if (this.thinkingBuffer || this.thinkingSignature !== undefined) {
yield thinkingMessage(this.thinkingBuffer, stopReason, {
...(this.thinkingSignature !== undefined ? { signature: this.thinkingSignature } : {}),
});
// A thinking block with empty text but a fidelity payload (GPT-5 encrypted reasoning)
// still produces a complete message — the fidelity is required when replaying history.
if (this.thinkingBuffer || hasFidelity(this.thinkingFidelity)) {
yield thinkingMessage(this.thinkingBuffer, stopReason, this.thinkingFidelity);
this.thinkingBuffer = "";
this.thinkingSignature = undefined;
this.thinkingFidelity = undefined;
}
}
@@ -559,18 +600,14 @@ export class EventTranslator {
yield partialText("stop", "", stopReason);
this.textStarted = false;
}
// A text segment with empty text but a signature (e.g. Gemini carrying a thoughtSignature on
// a text part) still produces a complete message — aligned with flushThinking, so the
// signature isn't lost or leaked into a later segment just because the buffer is empty.
if (this.textBuffer || this.textSignature !== undefined) {
yield assistantText(this.textBuffer, stopReason, {
...(this.textPhase != null ? { phase: this.textPhase } : {}),
...(this.textSignature !== undefined ? { signature: this.textSignature } : {}),
});
// A text segment with empty text but a fidelity payload (e.g. Gemini carrying a
// thoughtSignature on a text part, or a GPT-5 phase marker with no text) still produces a
// complete message — aligned with flushThinking, so the fidelity isn't lost or leaked into
// a later segment just because the buffer is empty.
if (this.textBuffer || hasFidelity(this.textFidelity)) {
yield assistantText(this.textBuffer, stopReason, this.textFidelity);
this.textBuffer = "";
this.textSignature = undefined;
// textPhase is sticky across segments: a later segment with the same phase isn't
// re-marked; it's updated when a different phase marker appears.
this.textFidelity = undefined;
}
}
@@ -601,7 +638,7 @@ export class EventTranslator {
completeArgs: null,
toolCallId: this.toolCallIds.allocate(providerKey),
providerKey,
signature: undefined,
fidelity: undefined,
emitted: false,
};
this.tools.set(providerKey, acc);
@@ -665,19 +702,37 @@ export function translateEvents(
// ---------------------------------------------------------------------------
/**
* Determines whether an error is an AgentHub / Provider response JSON parse error.
* Determines whether an error is an AgentHub / Provider "response delivered but unusable"
* parse or validation error. Two shapes (@prismshadow/agenthub 0.4.x):
*
* AgentHub uses `JSON.parse` internally to parse response bodies, and a parse failure throws a
* `SyntaxError`; hence we judge directly by exception type (the `name` check also covers
* cross-realm or deserialization-reconstructed errors, and we probe down the `cause` chain for
* wrapped errors). This is not an auth/parameter failure but an incomplete LLM Request, and
* should end with `malformed` and be handed to the engine to retry.
* - A raw `SyntaxError` from `JSON.parse` on a response body;
* - AgentHub's own error classes: `ToolCallArgumentParseError` (streamed tool-call arguments
* are not valid JSON — e.g. a stream truncated mid-arguments) and `EmptyResponseError`
* (a completed response carrying thinking only, which cannot be replayed).
*
* In every case the turn was **not committed** to AgentHub history: this is not an
* auth/parameter failure but an incomplete LLM Request, and should end with `malformed` and
* be handed to the engine to reconnect and retry. Judged by exception type with a `name`
* fallback (covers cross-realm or deserialization-reconstructed errors), probing down the
* `cause` chain for wrapped errors.
*/
export function isMalformedJsonParseError(error: unknown): boolean {
if (error == null) return false;
if (error instanceof SyntaxError) return true;
if (
error instanceof SyntaxError ||
error instanceof ToolCallArgumentParseError ||
error instanceof EmptyResponseError
) {
return true;
}
const err = error as { name?: string; cause?: unknown };
if (err.name === "SyntaxError") return true;
if (
err.name === "SyntaxError" ||
err.name === "ToolCallArgumentParseError" ||
err.name === "EmptyResponseError"
) {
return true;
}
if (err.cause && err.cause !== error) {
return isMalformedJsonParseError(err.cause);
}
@@ -692,7 +747,7 @@ export function isMalformedJsonParseError(error: unknown): boolean {
* usage_metadata|finish_reason"). This is not an auth/parameter failure but an incomplete LLM
* Request, and should end with `malformed` and be handed to the engine to reconnect and retry.
* AgentHub doesn't provide an error type for this, so we match by message prefix
* (@prismshadow/agenthub 0.3.x), probing down the `cause` chain.
* (verified against @prismshadow/agenthub 0.4.x), probing down the `cause` chain.
*/
export function isIncompleteStreamError(error: unknown): boolean {
if (error == null) return false;
+19 -20
View File
@@ -13,6 +13,7 @@ import type {
CompactionMode,
CompactionReason,
EventMessage,
Fidelity,
ImageUrlPayload,
InlineDataPayload,
InlineThinkingPayload,
@@ -61,30 +62,28 @@ export function sessionMeta(payload: SessionMetaPayload): SessionMetaMessage {
// Complete model_msg -----------------------------------------------------------
/**
* Provider fidelity fields: kept as-is and restored verbatim on replay.
* Builder convention: positional-argument-style builders carry these in a trailing `fidelity`
* object (narrowed via Pick per payload type — e.g. thinking only has signature); object-argument-
* style builders (toolCall) flatten `fidelity` fields into the parameter object alongside
* `stopReason`, mirroring the payload structure directly.
* Provider-fidelity payload (opaque, see `Fidelity` in types.ts): kept as-is and restored
* verbatim on replay. Builder convention: positional-argument-style builders take it as a
* trailing `fidelity` object; object-argument-style builders (toolCall) carry it in the
* parameter object alongside `stopReason`. An empty object is treated as absent — the
* payload field is only set when the fidelity carries at least one key.
*/
export interface FidelityFields {
phase?: string | null;
signature?: string;
function fidelityProp(fidelity?: Fidelity): { fidelity?: Fidelity } {
return fidelity !== undefined && Object.keys(fidelity).length > 0 ? { fidelity } : {};
}
export function textMessage(
role: Role,
text: string,
stopReason: StopReason = "completed",
fidelity?: FidelityFields,
fidelity?: Fidelity,
): OmniMessage<TextPayload> {
return model({
type: "text",
role,
text,
stop_reason: stopReason,
...(fidelity?.phase != null ? { phase: fidelity.phase } : {}),
...(fidelity?.signature !== undefined ? { signature: fidelity.signature } : {}),
...fidelityProp(fidelity),
});
}
@@ -93,7 +92,7 @@ export const userText = (text: string): OmniMessage<TextPayload> => textMessage(
export const assistantText = (
text: string,
stopReason: StopReason = "completed",
fidelity?: FidelityFields,
fidelity?: Fidelity,
): OmniMessage<TextPayload> => textMessage("assistant", text, stopReason, fidelity);
export function imageUrlMessage(imageUrl: string): OmniMessage<ImageUrlPayload> {
@@ -109,7 +108,7 @@ export function inlineData(
role: Role,
data: string,
mimeType: string,
fidelity?: Pick<FidelityFields, "signature">,
fidelity?: Fidelity,
): OmniMessage<InlineDataPayload> {
return model({
type: "inline_data",
@@ -117,28 +116,28 @@ export function inlineData(
data,
mime_type: mimeType,
stop_reason: "completed",
...(fidelity?.signature !== undefined ? { signature: fidelity.signature } : {}),
...fidelityProp(fidelity),
});
}
export function thinkingMessage(
thinking: string,
stopReason: StopReason = "completed",
fidelity?: Pick<FidelityFields, "signature">,
fidelity?: Fidelity,
): OmniMessage<ThinkingPayload> {
return model({
type: "thinking",
role: "assistant",
thinking,
stop_reason: stopReason,
...(fidelity?.signature !== undefined ? { signature: fidelity.signature } : {}),
...fidelityProp(fidelity),
});
}
export function inlineThinking(
data: string,
mimeType: string,
fidelity?: Pick<FidelityFields, "signature">,
fidelity?: Fidelity,
): OmniMessage<InlineThinkingPayload> {
return model({
type: "inline_thinking",
@@ -146,7 +145,7 @@ export function inlineThinking(
data,
mime_type: mimeType,
stop_reason: "completed",
...(fidelity?.signature !== undefined ? { signature: fidelity.signature } : {}),
...fidelityProp(fidelity),
});
}
@@ -155,7 +154,7 @@ export function toolCall(args: {
arguments: string;
toolCallId: string;
stopReason?: StopReason;
signature?: string;
fidelity?: Fidelity;
}): OmniMessage<ToolCallPayload> {
return model({
type: "tool_call",
@@ -164,7 +163,7 @@ export function toolCall(args: {
arguments: args.arguments,
tool_call_id: args.toolCallId,
stop_reason: args.stopReason ?? "completed",
...(args.signature !== undefined ? { signature: args.signature } : {}),
...fidelityProp(args.fidelity),
});
}
+23 -14
View File
@@ -99,15 +99,23 @@ export interface SessionMetaPayload {
// ---------------------------------------------------------------------------
// Docs: /docs/omni-message § "model_msg: complete payloads"
/**
* Provider-fidelity payload (mirrors AgentHub's `Fidelity`): an arbitrary JSON-style object of
* wire-level data the LLM client records to reproduce the original message on replay — thinking
* signatures, phase labels, encrypted reasoning, the upstream reasoning field name, etc. Opaque
* to PenguinHarness: written to the Trace as-is and passed back verbatim; some models **require**
* it when history is replayed (e.g. Claude thinking signatures, GPT-5 encrypted reasoning) —
* losing it breaks Session resumption.
*/
export type Fidelity = Record<string, unknown>;
export interface TextPayload {
type: "text";
role: Role;
text: string;
stop_reason?: StopReason;
/** Provider fidelity field: text phase marker (e.g. GPT-5 segments by phase), kept as-is and restored verbatim. */
phase?: string | null;
/** Provider fidelity field: signature, kept as-is and restored verbatim. */
signature?: string;
/** Provider-fidelity payload (e.g. `phase` for GPT-5 segment markers, `signature`), kept as-is and restored verbatim. */
fidelity?: Fidelity;
}
export interface ImageUrlPayload {
@@ -125,8 +133,8 @@ export interface InlineDataPayload {
data: string;
mime_type: string;
stop_reason?: StopReason;
/** Provider fidelity field: signature, kept as-is and restored verbatim. */
signature?: string;
/** Provider-fidelity payload, kept as-is and restored verbatim. */
fidelity?: Fidelity;
}
export interface ThinkingPayload {
@@ -135,11 +143,12 @@ export interface ThinkingPayload {
thinking: string;
stop_reason?: StopReason;
/**
* Provider fidelity field: thinking-block signature (Claude thinking blocks / redacted
* thinking, GPT-5 encrypted reasoning, etc. — **required** when some models replay history),
* kept as-is and restored verbatim — losing it breaks Session resumption.
* Provider-fidelity payload closing the thinking block (Claude thinking signatures / redacted
* thinking, GPT-5 encrypted reasoning, the OpenAI-compatible reasoning field name, etc. —
* **required** when some models replay history), kept as-is and restored verbatim — losing it
* breaks Session resumption.
*/
signature?: string;
fidelity?: Fidelity;
}
export interface InlineThinkingPayload {
@@ -149,8 +158,8 @@ export interface InlineThinkingPayload {
data: string;
mime_type: string;
stop_reason?: StopReason;
/** Provider fidelity field: signature, kept as-is and restored verbatim. */
signature?: string;
/** Provider-fidelity payload, kept as-is and restored verbatim. */
fidelity?: Fidelity;
}
export interface ToolCallPayload {
@@ -161,8 +170,8 @@ export interface ToolCallPayload {
arguments: string;
tool_call_id: string;
stop_reason?: StopReason;
/** Provider fidelity field: signature, kept as-is and restored verbatim. */
signature?: string;
/** Provider-fidelity payload, kept as-is and restored verbatim. */
fidelity?: Fidelity;
}
export interface ToolCallOutputPayload {
+37 -4
View File
@@ -22,6 +22,31 @@ const EXCERPT_MAX_CHARS = 2000;
/** Cap on title length (fallback truncation for when the model occasionally ignores the constraint). */
const TITLE_MAX_CHARS = 30;
/**
* Special message markers that must never leak into a title. These are machine-inserted
* XML-ish blocks (a skill invocation wraps the body in `<use_skills>…</use_skills>`, a
* subagent handoff / scheduled task prepend their own blocks) — meaningful to the runtime,
* noise in a title. The list is a fixed allowlist so ordinary angle-bracket text (e.g. a
* user pasting `<div>`) is left untouched.
*/
const MARKER_TAGS = ["use_skills", "handoff_from", "scheduled_task"];
/**
* Strips machine-inserted marker blocks (see MARKER_TAGS) from conversation text so titles are
* built from the human-meaningful body only — both the material sent to the model and the
* fallback derived from the raw first message. Removes the paired `<tag>…</tag>` block (any
* inner content, across lines) and any stray unpaired `<tag>` / `</tag>` left behind.
*/
export function stripConversationMarkers(text: string): string {
let out = text;
for (const tag of MARKER_TAGS) {
out = out
.replace(new RegExp(`<${tag}>[\\s\\S]*?</${tag}>`, "g"), "")
.replace(new RegExp(`</?${tag}>`, "g"), "");
}
return out.trim();
}
export interface SessionTitleResult {
/** The sanitized title; null when material is insufficient, the request fails, or the output is empty. */
title: string | null;
@@ -44,6 +69,7 @@ export function buildTitlePrompt(userExcerpt: string, assistantExcerpt: string):
"- Write the title in the SAME language the user is using.",
"- Keep it short: at most 6 words, or ~16 characters for CJK.",
"- Output ONLY the title text — no quotes, no trailing punctuation, no explanation.",
"- Answer immediately — do not think aloud or produce chain-of-thought.",
"",
"[User]",
clip(userExcerpt),
@@ -51,12 +77,15 @@ export function buildTitlePrompt(userExcerpt: string, assistantExcerpt: string):
if (assistantExcerpt.trim()) {
lines.push("", "[Assistant]", clip(assistantExcerpt));
}
// The trailing empty think block makes many reasoning models treat their thinking phase
// as already closed, so the one-off request spends its budget on the title itself.
lines.push("", "<think></think>");
return lines.join("\n");
}
/** Sanitizes model output into a title: strips leading/trailing quotes/brackets and trailing punctuation (until stable), collapses whitespace, and truncates if too long; returns null for an empty result. */
/** Sanitizes model output into a title: strips any leaked marker blocks and leading/trailing quotes/brackets and trailing punctuation (until stable), collapses whitespace, and truncates if too long; returns null for an empty result. */
export function sanitizeTitle(raw: string): string | null {
let t = raw.replace(/\s+/g, " ").trim();
let t = stripConversationMarkers(raw).replace(/\s+/g, " ").trim();
// Stripping quotes can expose more punctuation underneath (or vice versa), so strip repeatedly until stable.
for (let prev = ""; prev !== t;) {
prev = t;
@@ -81,10 +110,14 @@ export async function generateTitleWithLLM(
llm: LLMInterface,
args: { userText: string; assistantText: string; signal?: AbortSignal },
): Promise<SessionTitleResult> {
if (!args.userText.trim()) {
// Strip machine markers before the model ever sees the material, so a skill-invocation
// block (or handoff / scheduled-task marker) can't bleed into the generated title.
const userMaterial = stripConversationMarkers(args.userText);
const assistantMaterial = stripConversationMarkers(args.assistantText);
if (!userMaterial.trim()) {
return { title: null, usage: null };
}
const prompt = buildTitlePrompt(args.userText, args.assistantText);
const prompt = buildTitlePrompt(userMaterial, assistantMaterial);
const gen = llm.streamGenerate({
newMessages: [userText(prompt)],
...(args.signal ? { signal: args.signal } : {}),
+27 -6
View File
@@ -65,15 +65,26 @@ export interface SessionConfig {
inputImagesDir?: string;
}
/** Cap on captured title material (chars per side, matching buildTitlePrompt's truncation); stops accumulating once exceeded. */
const TITLE_MATERIAL_LIMIT = 2000;
/**
* Caps on captured title material (chars per side); accumulation stops once exceeded. The
* assistant body is capped tighter: a title only needs the opening of the answer, and hosts
* may start generating as soon as this much body text has streamed (see the Web server's
* early trigger) — a long answer would otherwise overrun the material.
*/
const TITLE_USER_MATERIAL_LIMIT = 2000;
const TITLE_ASSISTANT_MATERIAL_LIMIT = 1000;
/**
* Accumulates title material: the body text of complete text messages from the main session
* (no origin) — thinking and tool calls naturally don't count — and stops once the cap is hit.
*/
function appendTitleText(base: string, msg: OmniMessage, role: "user" | "assistant"): string {
if (base.length >= TITLE_MATERIAL_LIMIT) return base;
function appendTitleText(
base: string,
msg: OmniMessage,
role: "user" | "assistant",
limit: number,
): string {
if (base.length >= limit) return base;
if (msg.origin && msg.origin.length > 0) return base;
const p = msg.payload as { type?: string; role?: string; text?: string };
if (msg.type !== "model_msg" || p.type !== "text" || p.role !== role || !p.text) return base;
@@ -153,12 +164,22 @@ export class Session {
const capture = !this.titleMaterialFrozen;
if (capture) {
for (const m of newMessages) {
this.titleUserText = appendTitleText(this.titleUserText, m, "user");
this.titleUserText = appendTitleText(
this.titleUserText,
m,
"user",
TITLE_USER_MATERIAL_LIMIT,
);
}
}
for await (const msg of this.engine.run(newMessages, opts)) {
if (capture) {
this.titleAssistantText = appendTitleText(this.titleAssistantText, msg, "assistant");
this.titleAssistantText = appendTitleText(
this.titleAssistantText,
msg,
"assistant",
TITLE_ASSISTANT_MATERIAL_LIMIT,
);
}
yield msg;
}
+10
View File
@@ -26,6 +26,8 @@ import {
VAULT_KEYS_PLACEHOLDER,
SKILL_METADATA_PLACEHOLDER,
CWD_PLACEHOLDER,
PROVIDER_PLACEHOLDER,
MODEL_ID_PLACEHOLDER,
DATE_PLACEHOLDER,
defaultAgentsMd,
defaultSystemConfig,
@@ -83,6 +85,10 @@ export interface SessionEnvironmentValues {
agentId: string;
/** Absolute path to this Project's directory (system Prompt placeholder {{PROJECT_DIR}}; Agent State/scratchpad paths are derived from it). */
projectDir: string;
/** The session model's provider group (system Prompt placeholder {{PROVIDER}}; paired with modelId to form the model reference). */
provider: string;
/** The session model's upstream model id (system Prompt placeholder {{MODEL_ID}}). */
modelId: string;
platform: string;
osVersion: string;
date: string;
@@ -371,6 +377,10 @@ export function assembleSystemPrompt(
.join(sessionEnvironment?.sessionId ?? "")
.split(CWD_PLACEHOLDER)
.join(sessionEnvironment?.cwd ?? "")
.split(PROVIDER_PLACEHOLDER)
.join(sessionEnvironment?.provider ?? "")
.split(MODEL_ID_PLACEHOLDER)
.join(sessionEnvironment?.modelId ?? "")
.split(PLATFORM_PLACEHOLDER)
.join(sessionEnvironment?.platform ?? "")
.split(OS_VERSION_PLACEHOLDER)
+12 -3
View File
@@ -28,6 +28,8 @@ export const SESSION_ID_PLACEHOLDER = "{{SESSION_ID}}";
export const CWD_PLACEHOLDER = "{{CWD}}";
export const AGENT_ID_PLACEHOLDER = "{{AGENT_ID}}";
export const PROJECT_DIR_PLACEHOLDER = "{{PROJECT_DIR}}";
export const PROVIDER_PLACEHOLDER = "{{PROVIDER}}";
export const MODEL_ID_PLACEHOLDER = "{{MODEL_ID}}";
export const PLATFORM_PLACEHOLDER = "{{PLATFORM}}";
export const OS_VERSION_PLACEHOLDER = "{{OS_VERSION}}";
export const DATE_PLACEHOLDER = "{{DATE}}";
@@ -141,9 +143,11 @@ Skills are reusable instruction packages stored under <project_dir>/agents/<agen
- Platform: {{PLATFORM}}
- OS Version: {{OS_VERSION}}
- Date: {{DATE}}
- CWD: {{CWD}}
- Agent ID: {{AGENT_ID}}
- Project Dir: {{PROJECT_DIR}}
- Agent ID: {{AGENT_ID}}
- CWD: {{CWD}}
- Provider: {{PROVIDER}}
- Model ID: {{MODEL_ID}}
- Session ID: {{SESSION_ID}}`;
/**
@@ -247,7 +251,12 @@ function defaultBuiltinTools(): ToolDefinitionConfig[] {
model_id: {
type: "string",
description:
"Which model the subagent should use; defaults to the Project default model when omitted.",
"Which model the subagent should use, as the upstream model id. Must be given together with provider — a model is always referenced by the pair. Omit both to use the Project default model.",
},
provider: {
type: "string",
description:
"The provider group that model_id belongs to (see the Environment section's Provider). Required whenever model_id is given.",
},
yield_time_ms: {
type: "number",
+522 -138
View File
@@ -1,7 +1,7 @@
/**
* Built-in model catalog (single source of truth): official chat models that AgentHub can
* auto-route, shared by core's default config, server's initial config, and web/cli display.
* Data verified as of 2026-07-10.
* Data verified as of 2026-07-10 (Qwen Token Plan entries: 2026-07-20, per the plan's docs).
* Docs: packages/docs/content/models.{zh,en}.md (site path /docs/models) documents the
* provider groups and credential resolution described here.
*
@@ -17,10 +17,10 @@
*
* Scope: excludes deepseek-chat / deepseek-reasoner legacy aliases that AgentHub cannot
* auto-route (deprecated 2026-07-24), glm-5v-turbo (image input unsupported by AgentHub's GLM
* client), non-chat models (embedding / image generation / TTS), and Bedrock plus
* OpenRouter / SiliconFlow gateway mirror ids. Every model id in this catalog can be
* auto-routed by AgentHub via substring matching, so none set client_type; only custom
* OpenAI-protocol models need `client_type: "openai"`.
* client), non-chat models (embedding / image generation / TTS), and Bedrock. Direct-vendor
* ids are auto-routed by AgentHub and leave client_type unset; gateway entries (OpenRouter /
* SiliconFlow / Qwen Token Plan) can't be auto-routed, so they set `client_type: "openai"`
* and inline their preset base URL.
*
* This file imports no Node built-ins (type-only imports only), so it can be bundled directly
* for the browser.
@@ -41,8 +41,9 @@ export interface ModelProviderInfo {
/** Vendor's model list / docs page URL (frontend's "add model" dialog links this as "get model id"); none for custom. */
modelsUrl?: string;
/**
* Gateway's OpenAI-compatible endpoint (openrouter / siliconflow): used by the frontend's
* "add model" dialog to prefill base URL by group; left blank for direct vendors and custom.
* Gateway's OpenAI-compatible endpoint (openrouter / siliconflow / qwen-token-plan): used by
* the frontend's "add model" dialog to prefill base URL by group; left blank for direct
* vendors and custom.
*/
gatewayBaseUrl?: string;
}
@@ -66,11 +67,15 @@ export interface ModelCatalogEntry {
/** Each gateway's OpenAI-compatible endpoint (preset base URL for gateway models; also used as the provider's gatewayBaseUrl). */
const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
const SILICONFLOW_BASE_URL = "https://api.siliconflow.cn/v1";
const QWEN_TOKEN_PLAN_BASE_URL =
"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1";
const QWEN_PAYG_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1";
const FIREWORKS_BASE_URL = "https://api.fireworks.ai/inference/v1";
/**
* Provider list (web model page groups in this order): DeepSeek first (the default model's
* provider), followed by the OpenRouter and SiliconFlow gateways, then Google Gemini before
* Anthropic; custom groups custom OpenAI-protocol models and comes last.
* provider), followed by the OpenRouter, SiliconFlow, and Qwen Token Plan gateways, then
* Google Gemini before Anthropic; custom groups custom OpenAI-protocol models and comes last.
*/
export const MODEL_PROVIDERS: ModelProviderInfo[] = [
{
@@ -94,6 +99,15 @@ export const MODEL_PROVIDERS: ModelProviderInfo[] = [
modelsUrl: "https://openrouter.ai/models",
gatewayBaseUrl: OPENROUTER_BASE_URL,
},
{
id: "fireworks",
label: "Fireworks AI",
envKey: "OPENAI_API_KEY",
envBaseUrlKey: "OPENAI_BASE_URL",
apiKeyUrl: "https://app.fireworks.ai/settings/users/api-keys",
modelsUrl: "https://app.fireworks.ai/models",
gatewayBaseUrl: FIREWORKS_BASE_URL,
},
{
id: "siliconflow",
label: "SiliconFlow",
@@ -103,6 +117,25 @@ export const MODEL_PROVIDERS: ModelProviderInfo[] = [
modelsUrl: "https://cloud.siliconflow.cn/models",
gatewayBaseUrl: SILICONFLOW_BASE_URL,
},
{
id: "qwen-token-plan",
label: "Qwen Token Plan",
envKey: "OPENAI_API_KEY",
envBaseUrlKey: "OPENAI_BASE_URL",
apiKeyUrl: "https://platform.qianwenai.com/pricing/token-plan",
modelsUrl:
"https://platform.qianwenai.com/docs/token-plan/personal/token-plan-personal-overview",
gatewayBaseUrl: QWEN_TOKEN_PLAN_BASE_URL,
},
{
id: "qwen-pay-as-you-go",
label: "Qwen Pay-As-You-Go",
envKey: "OPENAI_API_KEY",
envBaseUrlKey: "OPENAI_BASE_URL",
apiKeyUrl: "https://platform.qianwenai.com/docs/api-reference/preparation/api-key",
modelsUrl: "https://www.qianwenai.com/models",
gatewayBaseUrl: QWEN_PAYG_BASE_URL,
},
{
id: "google",
label: "Google Gemini",
@@ -161,17 +194,14 @@ function usd(cacheRead: number, cacheWrite: number, output: number): ModelPricin
return { unit: "usd_per_mtok", cache_read: cacheRead, cache_write: cacheWrite, output };
}
/** Built-in model catalog (clustered by provider; within each provider, ordered by capability/price, highest first). */
/**
* Built-in model catalog, clustered by provider. Within each provider, entries are in
* dictionary order by model id, except that newer versions of the same series come first
* (e.g. gpt-5.6-* before gpt-5.5, claude-opus-4.8 before 4.7, glm-5.2 before glm-5). The
* order is precomputed by hand right here — no runtime sorting anywhere.
*/
export const MODEL_CATALOG: ModelCatalogEntry[] = [
// -- DeepSeek (official CNY pricing: cache hit / cache miss / output) --
{
modelId: "deepseek-v4-pro",
displayName: "DeepSeek V4 Pro",
provider: "deepseek",
contextWindow: 1000000,
pricing: cny(0.025, 3, 6),
supportsVision: false,
},
{
modelId: "deepseek-v4-flash",
displayName: "DeepSeek V4 Flash",
@@ -180,7 +210,438 @@ export const MODEL_CATALOG: ModelCatalogEntry[] = [
pricing: cny(0.02, 1, 2),
supportsVision: false,
},
// —— Anthropic ——
{
modelId: "deepseek-v4-pro",
displayName: "DeepSeek V4 Pro",
provider: "deepseek",
contextWindow: 1000000,
pricing: cny(0.025, 3, 6),
supportsVision: false,
},
// -- OpenRouter (gateway: OpenAI-compatible protocol, preset base URL). Entries added
// 2026-07-20 list no cache pricing on their OpenRouter pages, so cache_read carries the
// standard input price; their :free tier stores a genuine $0 price (not "unknown"), so
// costs correctly compute to 0. GPT models are uniformly vision-capable (OpenAI
// product-line policy) even where the gateway page omits the modality. --
{
modelId: "anthropic/claude-fable-5",
displayName: "Claude Fable 5",
provider: "openrouter",
contextWindow: 1000000,
pricing: usd(10, 10, 50),
supportsVision: true,
clientType: "openai",
baseUrl: OPENROUTER_BASE_URL,
},
{
modelId: "anthropic/claude-opus-4.8",
displayName: "Claude Opus 4.8",
provider: "openrouter",
contextWindow: 1000000,
pricing: usd(5, 5, 25),
supportsVision: true,
clientType: "openai",
baseUrl: OPENROUTER_BASE_URL,
},
{
modelId: "anthropic/claude-opus-4.7",
displayName: "Claude Opus 4.7",
provider: "openrouter",
contextWindow: 1000000,
pricing: usd(5, 5, 25),
supportsVision: true,
clientType: "openai",
baseUrl: OPENROUTER_BASE_URL,
},
{
modelId: "anthropic/claude-sonnet-5",
displayName: "Claude Sonnet 5",
provider: "openrouter",
contextWindow: 1000000,
pricing: usd(2, 2, 10),
supportsVision: true,
clientType: "openai",
baseUrl: OPENROUTER_BASE_URL,
},
{
modelId: "deepseek/deepseek-v4-flash",
displayName: "DeepSeek V4 Flash",
provider: "openrouter",
contextWindow: 1000000,
pricing: usd(0.09, 0.09, 0.18),
supportsVision: false,
clientType: "openai",
baseUrl: OPENROUTER_BASE_URL,
},
{
modelId: "deepseek/deepseek-v4-pro",
displayName: "DeepSeek V4 Pro",
provider: "openrouter",
contextWindow: 1000000,
pricing: usd(0.435, 0.435, 0.87),
supportsVision: false,
clientType: "openai",
baseUrl: OPENROUTER_BASE_URL,
},
{
modelId: "google/gemini-3.5-flash",
displayName: "Gemini 3.5 Flash",
provider: "openrouter",
contextWindow: 1000000,
pricing: usd(1.5, 1.5, 9),
supportsVision: true,
clientType: "openai",
baseUrl: OPENROUTER_BASE_URL,
},
{
// No official separate cache price published: cache_read uses the standard input price (no discount assumed).
modelId: "minimax/minimax-m3",
displayName: "MiniMax M3",
provider: "openrouter",
contextWindow: 1048576,
pricing: usd(0.06, 0.3, 1.2),
supportsVision: true,
clientType: "openai",
baseUrl: OPENROUTER_BASE_URL,
},
{
modelId: "moonshotai/kimi-k3",
displayName: "Kimi K3",
provider: "openrouter",
contextWindow: 1000000,
pricing: usd(3, 3, 15),
supportsVision: true,
clientType: "openai",
baseUrl: OPENROUTER_BASE_URL,
},
{
modelId: "nvidia/nemotron-3-ultra-550b-a55b:free",
displayName: "Nemotron 3 Ultra (free)",
provider: "openrouter",
contextWindow: 1000000,
pricing: usd(0, 0, 0),
supportsVision: false,
clientType: "openai",
baseUrl: OPENROUTER_BASE_URL,
},
{
modelId: "openai/gpt-5.6-sol",
displayName: "GPT-5.6 Sol",
provider: "openrouter",
contextWindow: 1000000,
pricing: usd(5, 5, 30),
supportsVision: true,
clientType: "openai",
baseUrl: OPENROUTER_BASE_URL,
},
{
modelId: "openai/gpt-5.6-terra",
displayName: "GPT-5.6 Terra",
provider: "openrouter",
contextWindow: 1000000,
pricing: usd(2.5, 2.5, 15),
supportsVision: true,
clientType: "openai",
baseUrl: OPENROUTER_BASE_URL,
},
{
modelId: "openai/gpt-5.5",
displayName: "GPT-5.5",
provider: "openrouter",
contextWindow: 1000000,
pricing: usd(5, 5, 30),
supportsVision: true,
clientType: "openai",
baseUrl: OPENROUTER_BASE_URL,
},
{
// No official separate cache price published: cache_read uses the standard input price.
modelId: "stepfun/step-3.7-flash",
displayName: "Step 3.7 Flash",
provider: "openrouter",
contextWindow: 256000,
pricing: usd(0.04, 0.2, 1.15),
supportsVision: true,
clientType: "openai",
baseUrl: OPENROUTER_BASE_URL,
},
{
modelId: "tencent/hy3",
displayName: "Hy3",
provider: "openrouter",
contextWindow: 262144,
pricing: usd(0.035, 0.14, 0.58),
supportsVision: false,
clientType: "openai",
baseUrl: OPENROUTER_BASE_URL,
},
{
modelId: "x-ai/grok-4.5",
displayName: "Grok 4.5",
provider: "openrouter",
contextWindow: 500000,
pricing: usd(2, 2, 6),
supportsVision: true,
clientType: "openai",
baseUrl: OPENROUTER_BASE_URL,
},
{
modelId: "xiaomi/mimo-v2.5",
displayName: "MiMo-V2.5",
provider: "openrouter",
contextWindow: 1048576,
pricing: usd(0.0028, 0.14, 0.28),
supportsVision: true,
clientType: "openai",
baseUrl: OPENROUTER_BASE_URL,
},
{
modelId: "z-ai/glm-5.2",
displayName: "GLM-5.2",
provider: "openrouter",
contextWindow: 1000000,
pricing: usd(0.93, 0.93, 3),
supportsVision: false,
clientType: "openai",
baseUrl: OPENROUTER_BASE_URL,
},
// -- Fireworks AI (gateway, standard serverless USD pricing: cached input / uncached
// input / output from each model's page; API ids use the accounts/fireworks/models/<slug>
// form) --
{
modelId: "accounts/fireworks/models/deepseek-v4-flash",
displayName: "DeepSeek V4 Flash",
provider: "fireworks",
contextWindow: 1000000,
pricing: usd(0.03, 0.14, 0.28),
supportsVision: false,
clientType: "openai",
baseUrl: FIREWORKS_BASE_URL,
},
{
modelId: "accounts/fireworks/models/deepseek-v4-pro",
displayName: "DeepSeek V4 Pro",
provider: "fireworks",
contextWindow: 1000000,
pricing: usd(0.15, 1.74, 3.48),
supportsVision: false,
clientType: "openai",
baseUrl: FIREWORKS_BASE_URL,
},
{
modelId: "accounts/fireworks/models/glm-5p2",
displayName: "GLM-5.2",
provider: "fireworks",
contextWindow: 1000000,
pricing: usd(0.14, 1.4, 4.4),
supportsVision: false,
clientType: "openai",
baseUrl: FIREWORKS_BASE_URL,
},
{
modelId: "accounts/fireworks/models/kimi-k2p7-code",
displayName: "Kimi K2.7 Code",
provider: "fireworks",
contextWindow: 262144,
pricing: usd(0.19, 0.95, 4),
supportsVision: true,
clientType: "openai",
baseUrl: FIREWORKS_BASE_URL,
},
{
modelId: "accounts/fireworks/models/minimax-m3",
displayName: "MiniMax M3",
provider: "fireworks",
contextWindow: 524288,
pricing: usd(0.06, 0.3, 1.2),
supportsVision: true,
clientType: "openai",
baseUrl: FIREWORKS_BASE_URL,
},
// -- SiliconFlow (gateway, official CNY pricing: cache hit / input / output) --
{
modelId: "deepseek-ai/DeepSeek-V4-Flash",
displayName: "DeepSeek V4 Flash",
provider: "siliconflow",
contextWindow: 1000000,
pricing: cny(0.02, 1, 2),
supportsVision: false,
clientType: "openai",
baseUrl: SILICONFLOW_BASE_URL,
},
{
modelId: "deepseek-ai/DeepSeek-V4-Pro",
displayName: "DeepSeek V4 Pro",
provider: "siliconflow",
contextWindow: 1000000,
pricing: cny(0.1, 12, 24),
supportsVision: false,
clientType: "openai",
baseUrl: SILICONFLOW_BASE_URL,
},
{
modelId: "meituan-longcat/LongCat-2.0",
displayName: "LongCat 2.0",
provider: "siliconflow",
contextWindow: 1000000,
pricing: cny(0.1, 5, 20),
supportsVision: false,
clientType: "openai",
baseUrl: SILICONFLOW_BASE_URL,
},
{
modelId: "moonshotai/Kimi-K2.7-Code",
displayName: "Kimi K2.7 Code",
provider: "siliconflow",
contextWindow: 262144,
pricing: cny(1.3, 6.5, 27),
supportsVision: true,
clientType: "openai",
baseUrl: SILICONFLOW_BASE_URL,
},
{
modelId: "zai-org/GLM-5.2",
displayName: "GLM-5.2",
provider: "siliconflow",
contextWindow: 1000000,
pricing: cny(2, 8, 28),
supportsVision: false,
clientType: "openai",
baseUrl: SILICONFLOW_BASE_URL,
},
// -- Qwen Token Plan (subscription gateway; vision flags per the plan's supported-model
// table). Pricing and context windows from each model's page at
// www.qianwenai.com/models/<id> (official CNY list prices; limited-time promotions such as
// the 20%/50% off discounts are not stored). qwen3.8-max-preview is preview-only with a
// quota-multiplier promotion and publishes no per-token list price nor a context window, so
// it carries no pricing and uses its family's 1M window. --
{
modelId: "deepseek-v4-pro",
displayName: "DeepSeek V4 Pro",
provider: "qwen-token-plan",
contextWindow: 1000000,
pricing: cny(1, 12, 24),
supportsVision: false,
clientType: "openai",
baseUrl: QWEN_TOKEN_PLAN_BASE_URL,
},
{
modelId: "glm-5.2",
displayName: "GLM-5.2",
provider: "qwen-token-plan",
contextWindow: 1048576,
pricing: cny(2, 8, 28),
supportsVision: false,
clientType: "openai",
baseUrl: QWEN_TOKEN_PLAN_BASE_URL,
},
{
modelId: "qwen3.8-max-preview",
displayName: "Qwen 3.8 Max Preview",
provider: "qwen-token-plan",
contextWindow: 1000000,
supportsVision: true,
clientType: "openai",
baseUrl: QWEN_TOKEN_PLAN_BASE_URL,
},
{
modelId: "qwen3.7-max",
displayName: "Qwen 3.7 Max",
provider: "qwen-token-plan",
contextWindow: 1000000,
pricing: cny(2.4, 12, 36),
supportsVision: false,
clientType: "openai",
baseUrl: QWEN_TOKEN_PLAN_BASE_URL,
},
{
modelId: "qwen3.7-plus",
displayName: "Qwen 3.7 Plus",
provider: "qwen-token-plan",
contextWindow: 1000000,
pricing: cny(0.4, 2, 8),
supportsVision: true,
clientType: "openai",
baseUrl: QWEN_TOKEN_PLAN_BASE_URL,
},
// -- Qwen Pay-As-You-Go (DashScope's OpenAI-compatible pay-per-token marketplace; official
// CNY list prices and specs from each model's page at www.qianwenai.com/models/<id> —
// resold third-party models keep their vendor-prefixed upstream ids) --
{
modelId: "kimi/kimi-k3",
displayName: "Kimi K3",
provider: "qwen-pay-as-you-go",
contextWindow: 1048576,
pricing: cny(2, 20, 100),
supportsVision: true,
clientType: "openai",
baseUrl: QWEN_PAYG_BASE_URL,
},
{
modelId: "qwen3.7-max",
displayName: "Qwen 3.7 Max",
provider: "qwen-pay-as-you-go",
contextWindow: 1000000,
pricing: cny(2.4, 12, 36),
supportsVision: false,
clientType: "openai",
baseUrl: QWEN_PAYG_BASE_URL,
},
{
modelId: "qwen3.7-plus",
displayName: "Qwen 3.7 Plus",
provider: "qwen-pay-as-you-go",
contextWindow: 1000000,
pricing: cny(0.4, 2, 8),
supportsVision: true,
clientType: "openai",
baseUrl: QWEN_PAYG_BASE_URL,
},
{
modelId: "ZHIPU/GLM-5.2",
displayName: "GLM-5.2",
provider: "qwen-pay-as-you-go",
contextWindow: 1048576,
pricing: cny(2, 8, 28),
supportsVision: false,
clientType: "openai",
baseUrl: QWEN_PAYG_BASE_URL,
},
// -- Google Gemini (official USD pricing) --
{
modelId: "gemini-3.5-flash",
displayName: "Gemini 3.5 Flash",
provider: "google",
contextWindow: 1048576,
pricing: usd(0.15, 1.5, 9),
supportsVision: true,
},
{
modelId: "gemini-3.1-flash-lite",
displayName: "Gemini 3.1 Flash-Lite",
provider: "google",
contextWindow: 1048576,
pricing: usd(0.025, 0.25, 1.5),
supportsVision: true,
},
{
// ≤200K input tier; >200K has official surcharge pricing (see file header comment).
modelId: "gemini-3.1-pro-preview",
displayName: "Gemini 3.1 Pro (Preview)",
provider: "google",
contextWindow: 1048576,
pricing: usd(0.2, 2, 12),
supportsVision: true,
},
{
modelId: "gemini-3-flash-preview",
displayName: "Gemini 3 Flash (Preview)",
provider: "google",
contextWindow: 1048576,
pricing: usd(0.05, 0.5, 3),
supportsVision: true,
},
// -- Anthropic (official USD pricing; cache write = 1.25 x input) --
{
modelId: "claude-opus-4-8",
displayName: "Claude Opus 4.8",
@@ -205,7 +666,7 @@ export const MODEL_CATALOG: ModelCatalogEntry[] = [
pricing: usd(0.3, 3.75, 15),
supportsVision: true,
},
// —— OpenAI ——
// -- OpenAI (official USD pricing) --
{
modelId: "gpt-5.5",
displayName: "GPT-5.5",
@@ -256,41 +717,7 @@ export const MODEL_CATALOG: ModelCatalogEntry[] = [
pricing: usd(30, 30, 180),
supportsVision: true,
},
// —— Google Gemini ——
{
// ≤200K input tier; >200K has official surcharge pricing (see file header comment).
modelId: "gemini-3.1-pro-preview",
displayName: "Gemini 3.1 Pro (Preview)",
provider: "google",
contextWindow: 1048576,
pricing: usd(0.2, 2, 12),
supportsVision: true,
},
{
modelId: "gemini-3.5-flash",
displayName: "Gemini 3.5 Flash",
provider: "google",
contextWindow: 1048576,
pricing: usd(0.15, 1.5, 9),
supportsVision: true,
},
{
modelId: "gemini-3-flash-preview",
displayName: "Gemini 3 Flash (Preview)",
provider: "google",
contextWindow: 1048576,
pricing: usd(0.05, 0.5, 3),
supportsVision: true,
},
{
modelId: "gemini-3.1-flash-lite",
displayName: "Gemini 3.1 Flash-Lite",
provider: "google",
contextWindow: 1048576,
pricing: usd(0.025, 0.25, 1.5),
supportsVision: true,
},
// —— Z.AI (GLM) ——
// -- Z.AI (GLM) --
{
modelId: "glm-5.2",
displayName: "GLM-5.2",
@@ -332,80 +759,6 @@ export const MODEL_CATALOG: ModelCatalogEntry[] = [
pricing: cny(0.7, 4, 21),
supportsVision: true,
},
// -- OpenRouter (gateway: uses OpenAI-compatible protocol, preset base URL) --
{
modelId: "xiaomi/mimo-v2.5",
displayName: "MiMo-V2.5",
provider: "openrouter",
contextWindow: 1048576,
pricing: usd(0.0028, 0.14, 0.28),
supportsVision: true,
clientType: "openai",
baseUrl: OPENROUTER_BASE_URL,
},
{
modelId: "tencent/hy3",
displayName: "Hy3",
provider: "openrouter",
contextWindow: 262144,
pricing: usd(0.035, 0.14, 0.58),
supportsVision: false,
clientType: "openai",
baseUrl: OPENROUTER_BASE_URL,
},
{
// No official separate cache price published: cache_read uses the standard input price (no discount assumed).
modelId: "minimax/minimax-m3",
displayName: "MiniMax M3",
provider: "openrouter",
contextWindow: 1048576,
pricing: usd(0.06, 0.3, 1.2),
supportsVision: true,
clientType: "openai",
baseUrl: OPENROUTER_BASE_URL,
},
{
// No official separate cache price published: cache_read uses the standard input price.
modelId: "stepfun/step-3.7-flash",
displayName: "Step 3.7 Flash",
provider: "openrouter",
contextWindow: 256000,
pricing: usd(0.04, 0.2, 1.15),
supportsVision: true,
clientType: "openai",
baseUrl: OPENROUTER_BASE_URL,
},
// -- SiliconFlow (gateway, official CNY pricing: cache hit / input / output) --
{
modelId: "zai-org/GLM-5.2",
displayName: "GLM-5.2",
provider: "siliconflow",
contextWindow: 1000000,
pricing: cny(2, 8, 28),
supportsVision: false,
clientType: "openai",
baseUrl: SILICONFLOW_BASE_URL,
},
{
modelId: "deepseek-ai/DeepSeek-V4-Pro",
displayName: "DeepSeek V4 Pro",
provider: "siliconflow",
contextWindow: 1000000,
pricing: cny(0.1, 12, 24),
supportsVision: false,
clientType: "openai",
baseUrl: SILICONFLOW_BASE_URL,
},
{
modelId: "meituan-longcat/LongCat-2.0",
displayName: "LongCat 2.0",
provider: "siliconflow",
contextWindow: 1000000,
pricing: cny(0.1, 5, 20),
supportsVision: false,
clientType: "openai",
baseUrl: SILICONFLOW_BASE_URL,
},
];
/** Looks up a catalog entry by (provider, upstream id) pair (**the sole catalog-matching entry point**); returns undefined if not in the catalog. */
@@ -416,15 +769,6 @@ export function catalogEntryFor(
return MODEL_CATALOG.find((m) => m.provider === provider && m.modelId === upstreamId);
}
/**
* Infers the provider for an upstream id from the built-in catalog (used to default
* `provider` on `model add`): if it matches a catalog entry, use that entry's provider
* (upstream ids are globally unique within the catalog); otherwise custom.
*/
export function inferProviderForUpstream(upstreamId: string): string {
return MODEL_CATALOG.find((m) => m.modelId === upstreamId)?.provider ?? "custom";
}
/** Looks up provider info by provider id; returns undefined for an unknown id. */
export function providerInfo(providerId: string): ModelProviderInfo | undefined {
return MODEL_PROVIDERS.find((p) => p.id === providerId);
@@ -487,3 +831,43 @@ export function presetModelEntries(): ModelEntry[] {
...(m.baseUrl !== undefined ? { base_url: m.baseUrl } : {}),
}));
}
/**
* The model's own homepage/detail page for the frontend's model-card link. Gateway groups
* have a stable per-model URL pattern (works for user-added ids in those groups too);
* direct-vendor models link to the vendor's model list/docs page; the Token Plan preview
* model has no dedicated page and links to the plan's model overview; custom and
* user-defined groups have no page to vouch for.
*/
export function modelHomepageUrl(provider: string, modelId: string): string | undefined {
if (provider === "openrouter") return `https://openrouter.ai/${modelId}`;
if (provider === "qwen-token-plan") {
return modelId === "qwen3.8-max-preview"
? providerInfo(provider)?.modelsUrl
: `https://www.qianwenai.com/models/${modelId}`;
}
if (provider === "fireworks") {
// API id "accounts/<owner>/models/<slug>" -> page "app.fireworks.ai/models/<owner>/<slug>";
// nonconforming (user-added) ids fall back to the models listing.
const m = /^accounts\/([^/]+)\/models\/(.+)$/.exec(modelId);
return m
? `https://app.fireworks.ai/models/${m[1]}/${m[2]}`
: providerInfo(provider)?.modelsUrl;
}
if (provider === "qwen-pay-as-you-go") {
return `https://www.qianwenai.com/models/${encodeURIComponent(modelId)}`;
}
if (provider === "zhipu") {
// Z.AI's per-model guide pages use the bare model id as the slug.
return `https://docs.z.ai/guides/llm/${modelId}`;
}
if (provider === "moonshot") {
// Moonshot's pricing pages: kimi-k2.6 -> chat-k26 (dot dropped); other ids fall back.
const m = /^kimi-k(\d+)\.(\d+)$/.exec(modelId);
return m
? `https://platform.kimi.com/docs/pricing/chat-k${m[1]}${m[2]}`
: providerInfo(provider)?.modelsUrl;
}
if (provider === "custom") return undefined;
return providerInfo(provider)?.modelsUrl;
}
+26 -34
View File
@@ -17,11 +17,17 @@
* the unique key — string concatenation like `<provider>/<id>` is forbidden anywhere in the
* pipeline. `model_id` is the upstream request id, sent to AgentHub unchanged; `default_model` /
* `vision_model` are paired `{ provider, model_id }` references (a TOML inline table).
*
* A caller always supplies the **complete pair**: `provider` is never guessed from the builtin
* catalog and never derived from whichever configured entry happens to carry the same
* `model_id`. Both halves or neither — a `model_id` without a `provider` is an error, not a
* lookup, because resolving it would silently point credentials and pricing at a vendor the
* caller never named.
*/
import fs from "node:fs/promises";
import path from "node:path";
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
import { inferProviderForUpstream, presetModelEntries } from "./model-catalog.js";
import { presetModelEntries } from "./model-catalog.js";
import { projectConfigPath } from "./paths.js";
/** Model reference: a `(provider, model_id)` pair (never string-concatenated anywhere). */
@@ -247,9 +253,11 @@ export async function saveProjectConfig(
/**
* Adds or updates a Model:
* - Upserts into `models`, deduplicated by the `(provider, model_id)` pair (provider may be
* omitted — the builtin catalog is used to infer the upstream id's group, falling back to
* custom if it can't be inferred);
* - Upserts into `models`, deduplicated by the `(provider, model_id)` pair — both halves are
* supplied by the caller, since the group is never guessed from the builtin catalog (a
* gateway reselling a vendor model keeps the vendor's upstream id, so a bare id names no
* single group, and guessing wrong files the caller's api_key under a vendor they never
* picked); a model outside every known group is added under `"custom"` explicitly;
* - If `api_key`/`base_url` are provided, they're written inline into the entry;
* - Set as the default Model (a paired reference) when `opts.setDefault` is true.
* Reads the existing config (or the default), saves after the change, and returns the updated
@@ -259,8 +267,8 @@ export async function addModel(
root: string,
projectId: string,
entry: {
/** provider group; inferred from the builtin catalog when omitted (`inferProviderForUpstream`, falling back to custom if it can't be inferred). */
provider?: string;
/** provider group (required; never inferred — pass `"custom"` for a model outside the known groups). */
provider: string;
/** Upstream model id (sent to AgentHub unchanged). */
model_id: string;
context_window?: number;
@@ -275,7 +283,7 @@ export async function addModel(
opts?: { setDefault?: boolean },
): Promise<ProjectConfig> {
const cfg = await loadProjectConfig(root, projectId);
const provider = entry.provider ?? inferProviderForUpstream(entry.model_id);
const { provider } = entry;
// upsert: layers new fields on top of the existing entry; fields not explicitly provided
// (e.g. context_window) keep their existing value, so a call like "just add an api_key"
@@ -400,35 +408,19 @@ export function getModel(cfg: ProjectConfig, ref: ModelRef): ModelEntry | undefi
}
/**
* Resolves a model reference (the **single entry point for "provider omitted"**, shared by core
* and CLI/server — never set up a second one):
* - `provider` given: validated for existence by exact paired reference;
* - `provider` omitted: an exact-match lookup on `model_id` (no fuzzy matching of any kind) —
* resolvable only when **exactly one** entry matches; 0 or multiple matches always report a
* clear error (an ambiguity error lists the candidate paired references).
* Validates a `(provider, model_id)` pair against the Project config and returns it as a
* `ModelRef` (the **single validation entry point**, shared by core and CLI/server — never set
* up a second one). Both halves are required: this only ever checks that the exact pair is
* configured, it never searches for a group to attach to a bare `model_id`. A pair the config
* doesn't have throws — a reference outside the config would leave credentials, pricing, and
* the context window unavailable at request time.
*/
export function resolveModelRef(cfg: ProjectConfig, modelId: string, provider?: string): ModelRef {
if (provider !== undefined) {
const ref: ModelRef = { provider, model_id: modelId };
if (!getModel(cfg, ref)) {
throw new Error(
`Model is not in the Project config: ${formatModelRef(ref)}. Use \`penguin config model list\` to see the configured models, or \`penguin config model add\` to add one.`,
);
}
return ref;
}
const candidates = cfg.models.filter((m) => m.model_id === modelId);
if (candidates.length === 1) {
return { provider: candidates[0]!.provider, model_id: modelId };
}
if (candidates.length === 0) {
export function resolveModelRef(cfg: ProjectConfig, modelId: string, provider: string): ModelRef {
const ref: ModelRef = { provider, model_id: modelId };
if (!getModel(cfg, ref)) {
throw new Error(
`Model is not in the Project config: no entry has model_id ${modelId}. Use \`penguin config model list\` to see the configured models, or \`penguin config model add\` to add one.`,
`Model is not in the Project config: ${formatModelRef(ref)}. Use \`penguin config model list\` to see the configured models, or \`penguin config model add\` to add one.`,
);
}
throw new Error(
`Ambiguous model reference: model_id ${modelId} matches multiple entries: ${candidates
.map((m) => formatModelRef({ provider: m.provider, model_id: m.model_id }))
.join(", ")}. Specify provider to give a paired reference.`,
);
return ref;
}