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:
+28
-16
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user