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:
@@ -36,7 +36,7 @@
|
||||
"build": "tsup"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prismshadow/agenthub": "^0.3.3",
|
||||
"@prismshadow/agenthub": "^0.4.0",
|
||||
"@prismshadow/penguin-skills": "workspace:*",
|
||||
"smol-toml": "^1.3.0",
|
||||
"yaml": "^2.5.0"
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
@@ -80,14 +80,18 @@ describe("Agent.createSession workspace handling", () => {
|
||||
expect(path.isAbsolute(session.workspaceDir)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a modelId that is not in the Project config with a clear error", async () => {
|
||||
it("rejects a model reference that is not in the Project config with a clear error", async () => {
|
||||
const agent = await createAgent();
|
||||
const ws = path.join(tmpRoot, "ws-bad-model");
|
||||
await fs.mkdir(ws, { recursive: true });
|
||||
// A reference outside the config is not silently allowed (the unique key is provider +
|
||||
// model_id); the error is thrown before creating the temp Workspace.
|
||||
await expect(
|
||||
agent.createSession({ workspaceDir: ws, modelId: "not-configured-model" }),
|
||||
agent.createSession({
|
||||
workspaceDir: ws,
|
||||
modelId: "not-configured-model",
|
||||
provider: "custom",
|
||||
}),
|
||||
).rejects.toThrow(/is not in the Project config/);
|
||||
await expect(
|
||||
agent.createSession({ workspaceDir: ws, modelId: "deepseek-v4-pro", provider: "openai" }),
|
||||
@@ -129,59 +133,62 @@ describe("Agent.createSession model reference ((provider, model_id) pair)", () =
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves a unique bare model_id and accepts an explicit pair", async () => {
|
||||
const agent = await createAgent();
|
||||
const ws = path.join(tmpRoot, "ws-ref-pair");
|
||||
await fs.mkdir(ws, { recursive: true });
|
||||
// Provider omitted: model_id is a globally unique exact match in the config -> resolves to that entry.
|
||||
const bare = await agent.createSession({ workspaceDir: ws, modelId: "deepseek-v4-flash" });
|
||||
try {
|
||||
expect(bare.provider).toBe("deepseek");
|
||||
expect(bare.modelId).toBe("deepseek-v4-flash");
|
||||
} finally {
|
||||
bare.dispose();
|
||||
}
|
||||
const paired = await agent.createSession({
|
||||
workspaceDir: ws,
|
||||
modelId: "claude-sonnet-4-6",
|
||||
provider: "anthropic",
|
||||
});
|
||||
try {
|
||||
expect(paired.provider).toBe("anthropic");
|
||||
expect(paired.modelId).toBe("claude-sonnet-4-6");
|
||||
} finally {
|
||||
paired.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an ambiguous bare model_id and a provider without modelId", async () => {
|
||||
// Two providers coexist with the same model_id: omitting provider throws an ambiguity
|
||||
// error (listing the candidate pair references).
|
||||
it("selects the entry named by the pair, even when a second group sells the same model_id", async () => {
|
||||
// A user-run proxy resells claude-sonnet-4-6 under the same upstream id: the two entries
|
||||
// coexist and the pair — not the bare id — decides which one (and therefore which
|
||||
// credential and base_url) the Session runs on.
|
||||
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
|
||||
provider: "myproxy",
|
||||
model_id: "claude-sonnet-4-6",
|
||||
});
|
||||
const agent = await createAgent();
|
||||
const ws = path.join(tmpRoot, "ws-ref-ambiguous");
|
||||
const ws = path.join(tmpRoot, "ws-ref-pair");
|
||||
await fs.mkdir(ws, { recursive: true });
|
||||
await expect(
|
||||
agent.createSession({ workspaceDir: ws, modelId: "claude-sonnet-4-6" }),
|
||||
).rejects.toThrow(/Ambiguous.*\(provider=anthropic, model_id=claude-sonnet-4-6\)/);
|
||||
// Adding provider resolves it.
|
||||
const session = await agent.createSession({
|
||||
const vendor = await agent.createSession({
|
||||
workspaceDir: ws,
|
||||
modelId: "claude-sonnet-4-6",
|
||||
provider: "anthropic",
|
||||
});
|
||||
try {
|
||||
expect(vendor.provider).toBe("anthropic");
|
||||
expect(vendor.modelId).toBe("claude-sonnet-4-6");
|
||||
} finally {
|
||||
vendor.dispose();
|
||||
}
|
||||
const proxied = await agent.createSession({
|
||||
workspaceDir: ws,
|
||||
modelId: "claude-sonnet-4-6",
|
||||
provider: "myproxy",
|
||||
});
|
||||
try {
|
||||
expect(session.provider).toBe("myproxy");
|
||||
expect(proxied.provider).toBe("myproxy");
|
||||
expect(proxied.modelId).toBe("claude-sonnet-4-6");
|
||||
} finally {
|
||||
proxied.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects half a reference: modelId without provider, and provider without modelId", async () => {
|
||||
const agent = await createAgent();
|
||||
const ws = path.join(tmpRoot, "ws-ref-half");
|
||||
await fs.mkdir(ws, { recursive: true });
|
||||
// A bare model_id is never resolved against the config, not even when exactly one entry
|
||||
// carries it (deepseek-v4-flash is unique here): the group is the caller's to name.
|
||||
await expect(
|
||||
agent.createSession({ workspaceDir: ws, modelId: "deepseek-v4-flash" }),
|
||||
).rejects.toThrow(/must be given as a \(provider, model_id\) pair/);
|
||||
// The mirror case: provider alone is not a reference either.
|
||||
await expect(agent.createSession({ workspaceDir: ws, provider: "deepseek" })).rejects.toThrow(
|
||||
/must be given as a \(provider, model_id\) pair/,
|
||||
);
|
||||
// Neither half given is the documented "use the Project default" path, not an error.
|
||||
const session = await agent.createSession({ workspaceDir: ws });
|
||||
try {
|
||||
expect(session.provider).toBe("deepseek");
|
||||
expect(session.modelId).toBe("deepseek-v4-pro");
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
// provider cannot be used alone (the reference must be a pair).
|
||||
await expect(agent.createSession({ workspaceDir: ws, provider: "anthropic" })).rejects.toThrow(
|
||||
/provider cannot be used alone/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -122,6 +122,8 @@ describe("Project Dir / Agent ID placeholders", () => {
|
||||
cwd: "/tmp/ws",
|
||||
agentId: "env_agent",
|
||||
projectDir: "/tmp/proj",
|
||||
provider: "deepseek",
|
||||
modelId: "deepseek-v4-pro",
|
||||
platform: "linux",
|
||||
osVersion: "test",
|
||||
date: "2026-07-08",
|
||||
|
||||
+167
-41
@@ -10,7 +10,11 @@
|
||||
* determination.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ThinkingLevel } from "@prismshadow/agenthub";
|
||||
import {
|
||||
EmptyResponseError,
|
||||
ThinkingLevel,
|
||||
ToolCallArgumentParseError,
|
||||
} from "@prismshadow/agenthub";
|
||||
import type { UniEvent, UniMessage, UsageMetadata } from "@prismshadow/agenthub";
|
||||
import type { LLMOutcome } from "../src/interfaces.js";
|
||||
|
||||
@@ -1105,6 +1109,37 @@ describe("isMalformedJsonParseError", () => {
|
||||
);
|
||||
expect(isMalformedJsonParseError(new Error("socket hang up"))).toBe(false);
|
||||
});
|
||||
|
||||
it("detects AgentHub 0.4 parse/validation error classes (truncated tool args, thinking-only)", () => {
|
||||
// A stream truncated mid-arguments surfaces as ToolCallArgumentParseError since agenthub
|
||||
// 0.4 (previously a raw SyntaxError) — must stay malformed so the engine reconnects.
|
||||
expect(
|
||||
isMalformedJsonParseError(
|
||||
new ToolCallArgumentParseError({
|
||||
client: "Claude5Client",
|
||||
toolName: "exec_command",
|
||||
toolCallId: "toolu_broken_1",
|
||||
rawArguments: '{"cmd": "ec',
|
||||
reason: "Unterminated string in JSON at position 11",
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
// A completed thinking-only response cannot be replayed (400 on the next turn): retrying
|
||||
// via malformed gives the model another chance instead of failing the turn.
|
||||
expect(
|
||||
isMalformedJsonParseError(
|
||||
new EmptyResponseError({ client: "Claude5Client", finishReason: "stop" }),
|
||||
),
|
||||
).toBe(true);
|
||||
// Also detectable via the name fallback and the cause chain.
|
||||
expect(
|
||||
isMalformedJsonParseError(
|
||||
new Error("request failed", {
|
||||
cause: new EmptyResponseError({ client: "GPT5_5Client", finishReason: null }),
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isIncompleteStreamError", () => {
|
||||
@@ -1337,35 +1372,40 @@ describe("GenerativeModel.streamGenerate outcome classification (PRN-013)", () =
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider fidelity fields (signature / phase)", () => {
|
||||
describe("provider fidelity payloads (opaque, AgentHub 0.4 semantics)", () => {
|
||||
const complete = (messages: ReturnType<typeof translateEvents>["messages"]) =>
|
||||
messages.filter((m) => !(m.payload as { type: string }).type.startsWith("partial_"));
|
||||
|
||||
it("captures the thinking signature arriving as an empty-text delta (Claude signature_delta)", () => {
|
||||
it("captures the thinking fidelity arriving as an empty-text delta (Claude signature_delta)", () => {
|
||||
const { messages } = translateEvents([
|
||||
ev({ content_items: [{ type: "thinking", thinking: "let me think" }] }),
|
||||
ev({ content_items: [{ type: "thinking", thinking: "", signature: "sig-abc" }] }),
|
||||
ev({
|
||||
content_items: [{ type: "thinking", thinking: "", fidelity: { signature: "sig-abc" } }],
|
||||
}),
|
||||
ev({ content_items: [{ type: "text", text: "answer" }] }),
|
||||
ev({ event_type: "stop", content_items: [], finish_reason: "stop" }),
|
||||
]);
|
||||
const thinking = complete(messages).find(
|
||||
(m) => (m.payload as { type: string }).type === "thinking",
|
||||
)!;
|
||||
expect((thinking.payload as { thinking: string; signature?: string }).thinking).toBe(
|
||||
"let me think",
|
||||
);
|
||||
expect((thinking.payload as { signature?: string }).signature).toBe("sig-abc");
|
||||
const p = thinking.payload as { thinking: string; fidelity?: Record<string, unknown> };
|
||||
expect(p.thinking).toBe("let me think");
|
||||
expect(p.fidelity).toEqual({ signature: "sig-abc" });
|
||||
});
|
||||
|
||||
it("splits adjacent thinking blocks on signature (redacted + normal keep their own signatures)", () => {
|
||||
it("splits adjacent thinking blocks on differing fidelity (redacted + normal keep their own)", () => {
|
||||
const { messages } = translateEvents([
|
||||
// A redacted block: sentinel text + signature arrive together (Claude content_block_start).
|
||||
// A redacted block: sentinel text + fidelity arrive together (Claude content_block_start).
|
||||
ev({
|
||||
content_items: [{ type: "thinking", thinking: "_REDACTED_THINKING", signature: "sig-red" }],
|
||||
content_items: [
|
||||
{ type: "thinking", thinking: "_REDACTED_THINKING", fidelity: { signature: "sig-red" } },
|
||||
],
|
||||
}),
|
||||
// The next, ordinary thinking block.
|
||||
ev({ content_items: [{ type: "thinking", thinking: "visible" }] }),
|
||||
ev({ content_items: [{ type: "thinking", thinking: "", signature: "sig-vis" }] }),
|
||||
ev({
|
||||
content_items: [{ type: "thinking", thinking: "", fidelity: { signature: "sig-vis" } }],
|
||||
}),
|
||||
ev({ event_type: "stop", content_items: [], finish_reason: "stop" }),
|
||||
]);
|
||||
const thinkings = complete(messages).filter(
|
||||
@@ -1373,49 +1413,125 @@ describe("provider fidelity fields (signature / phase)", () => {
|
||||
);
|
||||
expect(
|
||||
thinkings.map((m) => {
|
||||
const p = m.payload as { thinking: string; signature?: string };
|
||||
return [p.thinking, p.signature];
|
||||
const p = m.payload as { thinking: string; fidelity?: Record<string, unknown> };
|
||||
return [p.thinking, p.fidelity];
|
||||
}),
|
||||
).toEqual([
|
||||
["_REDACTED_THINKING", "sig-red"],
|
||||
["visible", "sig-vis"],
|
||||
["_REDACTED_THINKING", { signature: "sig-red" }],
|
||||
["visible", { signature: "sig-vis" }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits an empty-text thinking with signature (GPT-5 encrypted reasoning)", () => {
|
||||
it("keeps a run of equal fidelity as one thinking block (OpenAI-compatible reasoning_field per delta)", () => {
|
||||
const rf = { reasoning_field: "reasoning_content" };
|
||||
const { messages } = translateEvents([
|
||||
ev({ content_items: [{ type: "thinking", thinking: "", signature: '{"id":"rs_1"}' }] }),
|
||||
ev({ content_items: [{ type: "thinking", thinking: "step 1, ", fidelity: { ...rf } }] }),
|
||||
ev({ content_items: [{ type: "thinking", thinking: "step 2, ", fidelity: { ...rf } }] }),
|
||||
ev({ content_items: [{ type: "thinking", thinking: "done", fidelity: { ...rf } }] }),
|
||||
ev({ content_items: [{ type: "text", text: "answer" }] }),
|
||||
ev({ event_type: "stop", content_items: [], finish_reason: "stop" }),
|
||||
]);
|
||||
const thinking = complete(messages).find(
|
||||
const thinkings = complete(messages).filter(
|
||||
(m) => (m.payload as { type: string }).type === "thinking",
|
||||
)!;
|
||||
expect((thinking.payload as { thinking: string }).thinking).toBe("");
|
||||
expect((thinking.payload as { signature?: string }).signature).toBe('{"id":"rs_1"}');
|
||||
);
|
||||
expect(
|
||||
thinkings.map((m) => {
|
||||
const p = m.payload as { thinking: string; fidelity?: Record<string, unknown> };
|
||||
return [p.thinking, p.fidelity];
|
||||
}),
|
||||
).toEqual([["step 1, step 2, done", rf]]);
|
||||
});
|
||||
|
||||
it("splits text segments on phase markers arriving as empty-text deltas (GPT-5)", () => {
|
||||
it("emits an empty-text thinking with fidelity (GPT-5 encrypted reasoning) and splits on the next one", () => {
|
||||
const { messages } = translateEvents([
|
||||
ev({ content_items: [{ type: "text", text: "", phase: "planning" }] }),
|
||||
ev({
|
||||
content_items: [
|
||||
{ type: "thinking", thinking: "", fidelity: { id: "rs_1", encrypted_content: "aaa" } },
|
||||
],
|
||||
}),
|
||||
ev({
|
||||
content_items: [
|
||||
{ type: "thinking", thinking: "", fidelity: { id: "rs_2", encrypted_content: "bbb" } },
|
||||
],
|
||||
}),
|
||||
ev({ content_items: [{ type: "text", text: "answer" }] }),
|
||||
ev({ event_type: "stop", content_items: [], finish_reason: "stop" }),
|
||||
]);
|
||||
const thinkings = complete(messages).filter(
|
||||
(m) => (m.payload as { type: string }).type === "thinking",
|
||||
);
|
||||
expect(
|
||||
thinkings.map((m) => {
|
||||
const p = m.payload as { thinking: string; fidelity?: Record<string, unknown> };
|
||||
return [p.thinking, p.fidelity];
|
||||
}),
|
||||
).toEqual([
|
||||
["", { id: "rs_1", encrypted_content: "aaa" }],
|
||||
["", { id: "rs_2", encrypted_content: "bbb" }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("splits text segments on fidelity.phase markers arriving as empty-text deltas (GPT-5)", () => {
|
||||
const { messages } = translateEvents([
|
||||
ev({ content_items: [{ type: "text", text: "", fidelity: { phase: "planning" } }] }),
|
||||
ev({ content_items: [{ type: "text", text: "plan..." }] }),
|
||||
ev({ content_items: [{ type: "text", text: "", phase: "answer" }] }),
|
||||
ev({ content_items: [{ type: "text", text: "", fidelity: { phase: "answer" } }] }),
|
||||
ev({ content_items: [{ type: "text", text: "final" }] }),
|
||||
ev({ event_type: "stop", content_items: [], finish_reason: "stop" }),
|
||||
]);
|
||||
const texts = complete(messages).filter((m) => (m.payload as { type: string }).type === "text");
|
||||
expect(
|
||||
texts.map((m) => {
|
||||
const p = m.payload as { text: string; phase?: string | null };
|
||||
return [p.text, p.phase];
|
||||
const p = m.payload as { text: string; fidelity?: Record<string, unknown> };
|
||||
return [p.text, p.fidelity];
|
||||
}),
|
||||
).toEqual([
|
||||
["plan...", "planning"],
|
||||
["final", "answer"],
|
||||
["plan...", { phase: "planning" }],
|
||||
["final", { phase: "answer" }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("carries the tool_call signature through to the complete message", () => {
|
||||
it("closes a text segment on fidelity.signature: later text becomes its own segment (the signature must not cover unsigned text)", () => {
|
||||
const { messages } = translateEvents([
|
||||
// Gemini stamps a thoughtSignature on the text part it signed; whatever follows is
|
||||
// unsigned. Merging them would replay a signature covering text the provider never
|
||||
// signed, and the provider rejects the resumed turn.
|
||||
ev({ content_items: [{ type: "text", text: "part one", fidelity: { signature: "sigA" } }] }),
|
||||
ev({ content_items: [{ type: "text", text: "part two" }] }),
|
||||
ev({ event_type: "stop", content_items: [], finish_reason: "stop" }),
|
||||
]);
|
||||
const texts = complete(messages).filter((m) => (m.payload as { type: string }).type === "text");
|
||||
expect(
|
||||
texts.map((m) => {
|
||||
const p = m.payload as { text: string; fidelity?: Record<string, unknown> };
|
||||
return [p.text, p.fidelity];
|
||||
}),
|
||||
).toEqual([
|
||||
["part one", { signature: "sigA" }],
|
||||
["part two", undefined],
|
||||
]);
|
||||
});
|
||||
|
||||
it("accumulates fidelity keys across deltas of one text segment (phase marker + trailing signature)", () => {
|
||||
const { messages } = translateEvents([
|
||||
// GPT-5 opens the segment with a bare phase marker and signs it only at the end; a
|
||||
// replacing (rather than merging) assignment would drop the phase and lose the
|
||||
// segmentation on replay.
|
||||
ev({ content_items: [{ type: "text", text: "", fidelity: { phase: "answer" } }] }),
|
||||
ev({ content_items: [{ type: "text", text: "final" }] }),
|
||||
ev({ content_items: [{ type: "text", text: "", fidelity: { signature: "sigB" } }] }),
|
||||
ev({ event_type: "stop", content_items: [], finish_reason: "stop" }),
|
||||
]);
|
||||
const texts = complete(messages).filter((m) => (m.payload as { type: string }).type === "text");
|
||||
expect(
|
||||
texts.map((m) => {
|
||||
const p = m.payload as { text: string; fidelity?: Record<string, unknown> };
|
||||
return [p.text, p.fidelity];
|
||||
}),
|
||||
).toEqual([["final", { phase: "answer", signature: "sigB" }]]);
|
||||
});
|
||||
|
||||
it("carries the tool_call fidelity through to the complete message", () => {
|
||||
const { messages } = translateEvents([
|
||||
ev({
|
||||
content_items: [
|
||||
@@ -1424,7 +1540,7 @@ describe("provider fidelity fields (signature / phase)", () => {
|
||||
name: "exec_command",
|
||||
arguments: { cmd: "ls" },
|
||||
tool_call_id: "tc1",
|
||||
signature: "sig-tool",
|
||||
fidelity: { signature: "sig-tool" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -1433,32 +1549,42 @@ describe("provider fidelity fields (signature / phase)", () => {
|
||||
const tc = complete(messages).find(
|
||||
(m) => (m.payload as { type: string }).type === "tool_call",
|
||||
)!;
|
||||
expect((tc.payload as { signature?: string }).signature).toBe("sig-tool");
|
||||
expect((tc.payload as { fidelity?: Record<string, unknown> }).fidelity).toEqual({
|
||||
signature: "sig-tool",
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips fidelity fields back to UniMessage content items (setHistory path)", () => {
|
||||
it("round-trips fidelity payloads back to UniMessage content items (setHistory path)", () => {
|
||||
const uni = mergeOmniToUniMessage([
|
||||
thinkingMessage("deep", "completed", { signature: "sig-1" }),
|
||||
assistantText("hi", "completed", { phase: "answer", signature: "sig-2" }),
|
||||
toolCall({ name: "t", arguments: "{}", toolCallId: "tc1", signature: "sig-3" }),
|
||||
toolCall({ name: "t", arguments: "{}", toolCallId: "tc1", fidelity: { signature: "sig-3" } }),
|
||||
]);
|
||||
expect(uni.content_items).toEqual([
|
||||
{ type: "thinking", thinking: "deep", signature: "sig-1" },
|
||||
{ type: "text", text: "hi", phase: "answer", signature: "sig-2" },
|
||||
{ type: "tool_call", name: "t", arguments: {}, tool_call_id: "tc1", signature: "sig-3" },
|
||||
{ type: "thinking", thinking: "deep", fidelity: { signature: "sig-1" } },
|
||||
{ type: "text", text: "hi", fidelity: { phase: "answer", signature: "sig-2" } },
|
||||
{
|
||||
type: "tool_call",
|
||||
name: "t",
|
||||
arguments: {},
|
||||
tool_call_id: "tc1",
|
||||
fidelity: { signature: "sig-3" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("flushText signature parity (PR #39 review)", () => {
|
||||
it("emits an empty-text message carrying a text signature instead of dropping it", () => {
|
||||
describe("flushText fidelity parity (PR #39 review)", () => {
|
||||
it("emits an empty-text message carrying a text fidelity instead of dropping it", () => {
|
||||
const { messages } = translateEvents([
|
||||
ev({ content_items: [{ type: "text", text: "", signature: "sig-t" }] }),
|
||||
ev({ content_items: [{ type: "text", text: "", fidelity: { signature: "sig-t" } }] }),
|
||||
ev({ event_type: "stop", content_items: [], finish_reason: "stop" }),
|
||||
]);
|
||||
const text = messages.find((m) => (m.payload as { type: string }).type === "text")!;
|
||||
expect((text.payload as { text: string }).text).toBe("");
|
||||
expect((text.payload as { signature?: string }).signature).toBe("sig-t");
|
||||
expect((text.payload as { fidelity?: Record<string, unknown> }).fidelity).toEqual({
|
||||
signature: "sig-t",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,24 +6,31 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MODEL_CATALOG,
|
||||
MODEL_PROVIDERS,
|
||||
modelHomepageUrl,
|
||||
catalogEntryFor,
|
||||
inferProviderForUpstream,
|
||||
presetModelEntries,
|
||||
providerInfo,
|
||||
resolveModelEnv,
|
||||
} from "../src/state/index.js";
|
||||
|
||||
describe("model-catalog", () => {
|
||||
it("model ids are globally unique; DeepSeek comes first (the default model's provider)", () => {
|
||||
it("(provider, model_id) pairs are unique; DeepSeek comes first (the default model's provider)", () => {
|
||||
// Bare model ids may repeat across providers (a gateway reselling a vendor model keeps the
|
||||
// vendor's upstream id, e.g. Qwen Token Plan's glm-5.2 / deepseek-v4-pro) — uniqueness is
|
||||
// the (provider, model_id) pair, matching the catalog's sole lookup key (catalogEntryFor).
|
||||
const pairs = MODEL_CATALOG.map((m) => `${m.provider}\0${m.modelId}`);
|
||||
expect(new Set(pairs).size).toBe(pairs.length);
|
||||
const ids = MODEL_CATALOG.map((m) => m.modelId);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(MODEL_CATALOG[0]!.provider).toBe("deepseek");
|
||||
// Group order: DeepSeek first, followed by the OpenRouter and SiliconFlow gateways,
|
||||
// then Google Gemini before Anthropic, with custom last.
|
||||
// Group order: DeepSeek first, followed by the OpenRouter, SiliconFlow, and Qwen Token
|
||||
// Plan gateways, then Google Gemini before Anthropic, with custom last.
|
||||
expect(MODEL_PROVIDERS.map((p) => p.id)).toEqual([
|
||||
"deepseek",
|
||||
"openrouter",
|
||||
"fireworks",
|
||||
"siliconflow",
|
||||
"qwen-token-plan",
|
||||
"qwen-pay-as-you-go",
|
||||
"google",
|
||||
"anthropic",
|
||||
"openai",
|
||||
@@ -61,13 +68,26 @@ describe("model-catalog", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("all three price buckets are positive; context_window is a positive integer", () => {
|
||||
it("price buckets are positive (preview models without a list price omit pricing); context_window is a positive integer", () => {
|
||||
for (const m of MODEL_CATALOG) {
|
||||
expect(m.pricing, m.modelId).toBeDefined();
|
||||
expect(m.pricing!.unit).toBe("usd_per_mtok");
|
||||
expect(m.pricing!.cache_read).toBeGreaterThan(0);
|
||||
expect(m.pricing!.cache_write).toBeGreaterThan(0);
|
||||
expect(m.pricing!.output).toBeGreaterThan(0);
|
||||
if (m.provider === "qwen-token-plan" && m.modelId === "qwen3.8-max-preview") {
|
||||
// Preview-only model: the plan runs a quota-multiplier promotion and publishes no
|
||||
// per-token list price, so the entry carries none and costs read as 0 (same as
|
||||
// unpriced user models).
|
||||
expect(m.pricing, m.modelId).toBeUndefined();
|
||||
} else if (m.modelId.endsWith(":free")) {
|
||||
// Free-tier gateway model: a genuine $0 price (not "unknown"), so costs compute to 0.
|
||||
expect(m.pricing, m.modelId).toBeDefined();
|
||||
expect([m.pricing!.cache_read, m.pricing!.cache_write, m.pricing!.output]).toEqual([
|
||||
0, 0, 0,
|
||||
]);
|
||||
} else {
|
||||
expect(m.pricing, m.modelId).toBeDefined();
|
||||
expect(m.pricing!.unit).toBe("usd_per_mtok");
|
||||
expect(m.pricing!.cache_read).toBeGreaterThan(0);
|
||||
expect(m.pricing!.cache_write).toBeGreaterThan(0);
|
||||
expect(m.pricing!.output).toBeGreaterThan(0);
|
||||
}
|
||||
expect(Number.isInteger(m.contextWindow)).toBe(true);
|
||||
expect(m.contextWindow!).toBeGreaterThan(0);
|
||||
}
|
||||
@@ -78,9 +98,12 @@ describe("model-catalog", () => {
|
||||
expect(providerInfo("nonexistent")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("pair matching and provider-grouping inference: catalogEntryFor / inferProviderForUpstream", () => {
|
||||
// catalogEntryFor is the sole catalog lookup entry point: it matches on (group, upstream id)
|
||||
// pairs, so an identically named upstream id never matches across the wrong group.
|
||||
it("catalogEntryFor is the sole lookup and always takes the (provider, model_id) pair", () => {
|
||||
// It matches on (group, upstream id) pairs, so an identically named upstream id never
|
||||
// matches across the wrong group. There is no bare-id lookup at all: a gateway reselling a
|
||||
// vendor model keeps the vendor's upstream id, so a bare id names no single catalog entry
|
||||
// and the catalog never offers to pick one (`glm-5.2`, `qwen3.7-max`, `qwen3.7-plus` and
|
||||
// `deepseek-v4-pro` each appear under two groups).
|
||||
expect(catalogEntryFor("anthropic", "claude-sonnet-4-6")?.displayName).toBe(
|
||||
"Claude Sonnet 4.6",
|
||||
);
|
||||
@@ -88,11 +111,11 @@ describe("model-catalog", () => {
|
||||
// The upstream id itself may contain / (gateway models); it is never split apart.
|
||||
expect(catalogEntryFor("openrouter", "xiaomi/mimo-v2.5")?.displayName).toBe("MiMo-V2.5");
|
||||
expect(catalogEntryFor("custom", "my-own")).toBeUndefined();
|
||||
|
||||
// Inference for `model add` when --provider is omitted: a catalog hit yields its provider, otherwise custom.
|
||||
expect(inferProviderForUpstream("deepseek-v4-pro")).toBe("deepseek");
|
||||
expect(inferProviderForUpstream("xiaomi/mimo-v2.5")).toBe("openrouter");
|
||||
expect(inferProviderForUpstream("my-own-model")).toBe("custom");
|
||||
// Each group's entry for a resold id is reached only through that group.
|
||||
expect(catalogEntryFor("zhipu", "glm-5.2")?.contextWindow).toBe(1000000);
|
||||
expect(catalogEntryFor("qwen-token-plan", "glm-5.2")?.contextWindow).toBe(1048576);
|
||||
expect(catalogEntryFor("deepseek", "deepseek-v4-pro")?.provider).toBe("deepseek");
|
||||
expect(catalogEntryFor("qwen-token-plan", "deepseek-v4-pro")?.provider).toBe("qwen-token-plan");
|
||||
});
|
||||
|
||||
it("presetModelEntries: provider and bare upstream model_id are separate fields; gateway models inline base_url", () => {
|
||||
@@ -115,42 +138,124 @@ describe("model-catalog", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("gateway models (OpenRouter / SiliconFlow): openai protocol + preset base URL; env fallback is OPENAI_API_KEY", () => {
|
||||
it("gateway models (OpenRouter / SiliconFlow / Qwen Token Plan): openai protocol + preset base URL; env fallback is OPENAI_API_KEY", () => {
|
||||
const or = MODEL_CATALOG.filter((m) => m.provider === "openrouter");
|
||||
// Dictionary order, newer versions of a series first (gpt-5.6-* before gpt-5.5,
|
||||
// opus-4.8 before 4.7) — precomputed in the catalog, no runtime sorting.
|
||||
expect(or.map((m) => m.modelId)).toEqual([
|
||||
"xiaomi/mimo-v2.5",
|
||||
"tencent/hy3",
|
||||
"anthropic/claude-fable-5",
|
||||
"anthropic/claude-opus-4.8",
|
||||
"anthropic/claude-opus-4.7",
|
||||
"anthropic/claude-sonnet-5",
|
||||
"deepseek/deepseek-v4-flash",
|
||||
"deepseek/deepseek-v4-pro",
|
||||
"google/gemini-3.5-flash",
|
||||
"minimax/minimax-m3",
|
||||
"moonshotai/kimi-k3",
|
||||
"nvidia/nemotron-3-ultra-550b-a55b:free",
|
||||
"openai/gpt-5.6-sol",
|
||||
"openai/gpt-5.6-terra",
|
||||
"openai/gpt-5.5",
|
||||
"stepfun/step-3.7-flash",
|
||||
"tencent/hy3",
|
||||
"x-ai/grok-4.5",
|
||||
"xiaomi/mimo-v2.5",
|
||||
"z-ai/glm-5.2",
|
||||
]);
|
||||
for (const m of or) {
|
||||
expect(m.clientType).toBe("openai");
|
||||
expect(m.baseUrl).toBe("https://openrouter.ai/api/v1");
|
||||
}
|
||||
const fw = MODEL_CATALOG.filter((m) => m.provider === "fireworks");
|
||||
expect(fw.map((m) => [m.modelId, m.supportsVision])).toEqual([
|
||||
["accounts/fireworks/models/deepseek-v4-flash", false],
|
||||
["accounts/fireworks/models/deepseek-v4-pro", false],
|
||||
["accounts/fireworks/models/glm-5p2", false],
|
||||
["accounts/fireworks/models/kimi-k2p7-code", true],
|
||||
["accounts/fireworks/models/minimax-m3", true],
|
||||
]);
|
||||
for (const m of fw) {
|
||||
expect(m.clientType).toBe("openai");
|
||||
expect(m.baseUrl).toBe("https://api.fireworks.ai/inference/v1");
|
||||
}
|
||||
const sf = MODEL_CATALOG.filter((m) => m.provider === "siliconflow");
|
||||
expect(sf.map((m) => m.modelId)).toEqual([
|
||||
"zai-org/GLM-5.2",
|
||||
"deepseek-ai/DeepSeek-V4-Flash",
|
||||
"deepseek-ai/DeepSeek-V4-Pro",
|
||||
"meituan-longcat/LongCat-2.0",
|
||||
"moonshotai/Kimi-K2.7-Code",
|
||||
"zai-org/GLM-5.2",
|
||||
]);
|
||||
for (const m of sf) {
|
||||
expect(m.clientType).toBe("openai");
|
||||
expect(m.baseUrl).toBe("https://api.siliconflow.cn/v1");
|
||||
}
|
||||
const qtp = MODEL_CATALOG.filter((m) => m.provider === "qwen-token-plan");
|
||||
expect(qtp.map((m) => m.modelId)).toEqual([
|
||||
"deepseek-v4-pro",
|
||||
"glm-5.2",
|
||||
"qwen3.8-max-preview",
|
||||
"qwen3.7-max",
|
||||
"qwen3.7-plus",
|
||||
]);
|
||||
for (const m of qtp) {
|
||||
expect(m.clientType).toBe("openai");
|
||||
expect(m.baseUrl).toBe("https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1");
|
||||
}
|
||||
// Vision flags per the plan's supported-model table: 3.8-max-preview and 3.7-plus see images.
|
||||
expect(qtp.map((m) => [m.modelId, m.supportsVision])).toEqual([
|
||||
["deepseek-v4-pro", false],
|
||||
["glm-5.2", false],
|
||||
["qwen3.8-max-preview", true],
|
||||
["qwen3.7-max", false],
|
||||
["qwen3.7-plus", true],
|
||||
]);
|
||||
const qpayg = MODEL_CATALOG.filter((m) => m.provider === "qwen-pay-as-you-go");
|
||||
expect(qpayg.map((m) => [m.modelId, m.supportsVision])).toEqual([
|
||||
["kimi/kimi-k3", true],
|
||||
["qwen3.7-max", false],
|
||||
["qwen3.7-plus", true],
|
||||
["ZHIPU/GLM-5.2", false],
|
||||
]);
|
||||
for (const m of qpayg) {
|
||||
expect(m.clientType).toBe("openai");
|
||||
expect(m.baseUrl).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1");
|
||||
}
|
||||
// Routed through AgentHub's OpenAI client -> when the credential is left blank it reads OPENAI_API_KEY (not the provider's own env var name).
|
||||
for (const id of ["openrouter", "siliconflow", "custom"]) {
|
||||
for (const id of [
|
||||
"openrouter",
|
||||
"fireworks",
|
||||
"siliconflow",
|
||||
"qwen-token-plan",
|
||||
"qwen-pay-as-you-go",
|
||||
"custom",
|
||||
]) {
|
||||
expect(providerInfo(id)!.envKey).toBe("OPENAI_API_KEY");
|
||||
expect(providerInfo(id)!.envBaseUrlKey).toBe("OPENAI_BASE_URL");
|
||||
}
|
||||
// gatewayBaseUrl (prefilled by group in the frontend's "add model" dialog) is only carried by the two gateway providers.
|
||||
// gatewayBaseUrl (prefilled by group in the frontend's "add model" dialog) is only carried by the gateway providers.
|
||||
expect(providerInfo("openrouter")!.gatewayBaseUrl).toBe("https://openrouter.ai/api/v1");
|
||||
expect(providerInfo("siliconflow")!.gatewayBaseUrl).toBe("https://api.siliconflow.cn/v1");
|
||||
expect(providerInfo("qwen-token-plan")!.gatewayBaseUrl).toBe(
|
||||
"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
|
||||
);
|
||||
expect(providerInfo("qwen-pay-as-you-go")!.gatewayBaseUrl).toBe(
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
);
|
||||
expect(providerInfo("fireworks")!.gatewayBaseUrl).toBe("https://api.fireworks.ai/inference/v1");
|
||||
const GATEWAYS = [
|
||||
"openrouter",
|
||||
"fireworks",
|
||||
"siliconflow",
|
||||
"qwen-token-plan",
|
||||
"qwen-pay-as-you-go",
|
||||
];
|
||||
for (const p of MODEL_PROVIDERS) {
|
||||
if (p.id !== "openrouter" && p.id !== "siliconflow") {
|
||||
if (!GATEWAYS.includes(p.id)) {
|
||||
expect(p.gatewayBaseUrl, p.id).toBeUndefined();
|
||||
}
|
||||
}
|
||||
const gateway = [...or, ...sf];
|
||||
const gateway = [...or, ...fw, ...sf, ...qtp, ...qpayg];
|
||||
// Pricing (USD): MiMo v2.5 and Hy3.
|
||||
const mimo = MODEL_CATALOG.find((m) => m.modelId === "xiaomi/mimo-v2.5")!.pricing!;
|
||||
expect([mimo.cache_read, mimo.cache_write, mimo.output]).toEqual([0.0028, 0.14, 0.28]);
|
||||
@@ -209,4 +314,47 @@ describe("resolveModelEnv (PRN-021: env fallback resolved by AgentHub routing ru
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("modelHomepageUrl: gateway per-model pages, vendor docs fallback, none for custom groups", () => {
|
||||
// Gateway URL patterns work for user-added ids in those groups too (not catalog-gated).
|
||||
expect(modelHomepageUrl("openrouter", "anthropic/claude-fable-5")).toBe(
|
||||
"https://openrouter.ai/anthropic/claude-fable-5",
|
||||
);
|
||||
expect(modelHomepageUrl("openrouter", "someone/new-model")).toBe(
|
||||
"https://openrouter.ai/someone/new-model",
|
||||
);
|
||||
expect(modelHomepageUrl("qwen-token-plan", "qwen3.7-plus")).toBe(
|
||||
"https://www.qianwenai.com/models/qwen3.7-plus",
|
||||
);
|
||||
// Fireworks maps the accounts/<owner>/models/<slug> API id to its page path; other ids
|
||||
// fall back to the models listing.
|
||||
expect(modelHomepageUrl("fireworks", "accounts/fireworks/models/glm-5p2")).toBe(
|
||||
"https://app.fireworks.ai/models/fireworks/glm-5p2",
|
||||
);
|
||||
expect(modelHomepageUrl("fireworks", "my-own-id")).toBe("https://app.fireworks.ai/models");
|
||||
// Pay-as-you-go resells third-party models under slash-prefixed ids: the id is URL-encoded.
|
||||
expect(modelHomepageUrl("qwen-pay-as-you-go", "ZHIPU/GLM-5.2")).toBe(
|
||||
"https://www.qianwenai.com/models/ZHIPU%2FGLM-5.2",
|
||||
);
|
||||
// The preview model has no dedicated page: falls back to the plan's model overview.
|
||||
expect(modelHomepageUrl("qwen-token-plan", "qwen3.8-max-preview")).toBe(
|
||||
providerInfo("qwen-token-plan")!.modelsUrl,
|
||||
);
|
||||
// Direct vendors link to the vendor's model docs page.
|
||||
expect(modelHomepageUrl("deepseek", "deepseek-v4-pro")).toBe(
|
||||
"https://api-docs.deepseek.com/quick_start/pricing",
|
||||
);
|
||||
// Z.AI and Moonshot have per-model pages (Moonshot drops the dot: kimi-k2.6 -> chat-k26).
|
||||
expect(modelHomepageUrl("zhipu", "glm-5.2")).toBe("https://docs.z.ai/guides/llm/glm-5.2");
|
||||
expect(modelHomepageUrl("moonshot", "kimi-k2.6")).toBe(
|
||||
"https://platform.kimi.com/docs/pricing/chat-k26",
|
||||
);
|
||||
expect(modelHomepageUrl("moonshot", "kimi-k2.5")).toBe(
|
||||
"https://platform.kimi.com/docs/pricing/chat-k25",
|
||||
);
|
||||
expect(modelHomepageUrl("moonshot", "my-own")).toBe("https://platform.kimi.com/docs/pricing");
|
||||
// Custom and user-defined groups have no page to vouch for.
|
||||
expect(modelHomepageUrl("custom", "my-model")).toBeUndefined();
|
||||
expect(modelHomepageUrl("my-own-gateway", "x")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
generateTitleWithLLM,
|
||||
sanitizeTitle,
|
||||
Session,
|
||||
stripConversationMarkers,
|
||||
thinkingMessage,
|
||||
tokenUsage,
|
||||
userText,
|
||||
@@ -119,6 +120,24 @@ describe("session-title", () => {
|
||||
expect(sanitizeTitle("『标题』!")).toBe("标题");
|
||||
expect(sanitizeTitle(" \n ")).toBeNull();
|
||||
expect(sanitizeTitle("x".repeat(50))).toHaveLength(30);
|
||||
// A leaked <use_skills> block is stripped from the model output.
|
||||
expect(sanitizeTitle("<use_skills>\nskills: web-design\n</use_skills>\n构建落地页")).toBe(
|
||||
"构建落地页",
|
||||
);
|
||||
});
|
||||
|
||||
it("stripConversationMarkers: removes machine marker blocks, keeps the human body", () => {
|
||||
// The skill-invocation block that wraps a first user message must not reach the title.
|
||||
expect(
|
||||
stripConversationMarkers(
|
||||
"<use_skills>\nskills: penguin-sdk, web-design\n</use_skills>\n做一个 RAG 应用",
|
||||
),
|
||||
).toBe("做一个 RAG 应用");
|
||||
// Handoff and scheduled-task markers are stripped too; ordinary angle-bracket text stays.
|
||||
expect(stripConversationMarkers("<handoff_from>data_analyst</handoff_from>继续分析")).toBe(
|
||||
"继续分析",
|
||||
);
|
||||
expect(stripConversationMarkers("render a <div> element")).toBe("render a <div> element");
|
||||
});
|
||||
|
||||
it("Session.generateTitle: sends via createBareLLM; returns null when no factory is provided", async () => {
|
||||
@@ -162,6 +181,10 @@ describe("session-title", () => {
|
||||
// Material = the first Task's user text + model text (thinking does not count), matching
|
||||
// buildTitlePrompt's shape.
|
||||
expect(seen[0]).toBe(buildTitlePrompt("user question", "answer body"));
|
||||
// Anti-CoT shape: an explicit no-thinking rule, and the prompt ends with an empty think
|
||||
// block so reasoning models treat their thinking phase as already closed.
|
||||
expect(seen[0]).toContain("do not think aloud");
|
||||
expect(seen[0]!.endsWith("<think></think>")).toBe(true);
|
||||
|
||||
// No request is sent when no material has been collected (run was never called).
|
||||
const idle = new Session({
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
skillsDir,
|
||||
systemConfigPath,
|
||||
toolsDir,
|
||||
type ModelRef,
|
||||
type ProjectConfig,
|
||||
} from "../src/state/index.js";
|
||||
import { sessionEnvironment } from "../src/internal/session-support.js";
|
||||
@@ -288,6 +289,8 @@ describe("assembleSystemPrompt", () => {
|
||||
sessionEnvironment("/tmp/penguin-ws", "session-test-1", {
|
||||
agentId: DEFAULT_AGENT_ID,
|
||||
projectDir: "/tmp/proj",
|
||||
provider: "deepseek",
|
||||
modelId: "deepseek-v4-pro",
|
||||
}),
|
||||
);
|
||||
expect(prompt).toContain("AGENTS.md");
|
||||
@@ -341,6 +344,8 @@ describe("assembleSystemPrompt", () => {
|
||||
cwd: "/tmp/ws",
|
||||
agentId: "agent-x",
|
||||
projectDir: "/tmp/proj",
|
||||
provider: "deepseek",
|
||||
modelId: "deepseek-v4-pro",
|
||||
platform: "darwin",
|
||||
osVersion: "Darwin 25.0.0",
|
||||
date: "2026-06-30",
|
||||
@@ -393,6 +398,8 @@ describe("assembleSystemPrompt", () => {
|
||||
cwd: "/tmp/ws",
|
||||
agentId: "agent-x",
|
||||
projectDir: "/tmp/proj",
|
||||
provider: "deepseek",
|
||||
modelId: "deepseek-v4-pro",
|
||||
platform: "darwin",
|
||||
osVersion: "Darwin 25.0.0",
|
||||
date: "2026-06-30",
|
||||
@@ -466,7 +473,7 @@ describe("assembleSystemPrompt", () => {
|
||||
const env = sessionEnvironment(
|
||||
"/tmp/penguin-ws",
|
||||
"session-test-1",
|
||||
{ agentId: "agent-x", projectDir: "/tmp/proj" },
|
||||
{ agentId: "agent-x", projectDir: "/tmp/proj", provider: "openai", modelId: "gpt-5.5" },
|
||||
new Date("2026-06-30T00:00:00"),
|
||||
);
|
||||
const prompt = assembleSystemPrompt(state, env);
|
||||
@@ -476,15 +483,19 @@ describe("assembleSystemPrompt", () => {
|
||||
expect(prompt).toContain("CWD: /tmp/penguin-ws");
|
||||
expect(prompt).toContain("Agent ID: agent-x");
|
||||
expect(prompt).toContain("Project Dir: /tmp/proj");
|
||||
expect(prompt).toContain("Provider: openai");
|
||||
expect(prompt).toContain("Model ID: gpt-5.5");
|
||||
expect(prompt).toContain("Platform:");
|
||||
expect(prompt).toContain("OS Version:");
|
||||
expect(prompt).toContain("Date: 2026-06-30");
|
||||
expect(prompt.indexOf("Platform:")).toBeLessThan(prompt.indexOf("OS Version:"));
|
||||
expect(prompt.indexOf("OS Version:")).toBeLessThan(prompt.indexOf("Date:"));
|
||||
expect(prompt.indexOf("Date:")).toBeLessThan(prompt.indexOf("CWD:"));
|
||||
expect(prompt.indexOf("CWD:")).toBeLessThan(prompt.indexOf("Agent ID:"));
|
||||
expect(prompt.indexOf("Agent ID:")).toBeLessThan(prompt.indexOf("Project Dir:"));
|
||||
expect(prompt.indexOf("Project Dir:")).toBeLessThan(prompt.indexOf("Session ID:"));
|
||||
expect(prompt.indexOf("Date:")).toBeLessThan(prompt.indexOf("Project Dir:"));
|
||||
expect(prompt.indexOf("Project Dir:")).toBeLessThan(prompt.indexOf("Agent ID:"));
|
||||
expect(prompt.indexOf("Agent ID:")).toBeLessThan(prompt.indexOf("CWD:"));
|
||||
expect(prompt.indexOf("CWD:")).toBeLessThan(prompt.indexOf("Provider:"));
|
||||
expect(prompt.indexOf("Provider:")).toBeLessThan(prompt.indexOf("Model ID:"));
|
||||
expect(prompt.indexOf("Model ID:")).toBeLessThan(prompt.indexOf("Session ID:"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -531,15 +542,41 @@ describe("project-config round trip", () => {
|
||||
expect(getModel(loaded, { provider: "custom", model_id: "unknown-model" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("infers the provider from the builtin catalog when addModel omits it", async () => {
|
||||
await addModel(tmpRoot, DEFAULT_PROJECT_ID, { model_id: "claude-sonnet-4-6" });
|
||||
await addModel(tmpRoot, DEFAULT_PROJECT_ID, { model_id: "my-own-model" });
|
||||
it("addModel files the entry under the provider it was given, never one of its own choosing", async () => {
|
||||
// provider is a required field: nothing is inferred from the builtin catalog, so a model
|
||||
// outside the known groups is filed under custom only because the caller said so. glm-5.2
|
||||
// is sold by both the Qwen Token Plan gateway and Zhipu — the caller names which one, and
|
||||
// the entry (with its api_key) lands in exactly that group.
|
||||
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
|
||||
provider: "anthropic",
|
||||
model_id: "claude-sonnet-4-6",
|
||||
});
|
||||
await addModel(tmpRoot, DEFAULT_PROJECT_ID, { provider: "custom", model_id: "my-own-model" });
|
||||
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
|
||||
provider: "zhipu",
|
||||
model_id: "glm-5.2",
|
||||
api_key: "sk-zhipu",
|
||||
});
|
||||
const loaded = await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID);
|
||||
// A catalog hit gets its provider; otherwise it falls back to custom.
|
||||
expect(
|
||||
getModel(loaded, { provider: "anthropic", model_id: "claude-sonnet-4-6" }),
|
||||
).toBeDefined();
|
||||
expect(getModel(loaded, { provider: "custom", model_id: "my-own-model" })).toBeDefined();
|
||||
expect(getModel(loaded, { provider: "zhipu", model_id: "glm-5.2" })?.api_key).toBe("sk-zhipu");
|
||||
// The key never leaks into the other group that resells the same bare id.
|
||||
expect(
|
||||
getModel(loaded, { provider: "qwen-token-plan", model_id: "glm-5.2" })?.api_key,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("addModel requires an explicit provider: a bare model_id does not type-check", () => {
|
||||
// Compile-time contract (asserted by `pnpm typecheck`, which includes this file): with the
|
||||
// catalog inference gone there is nothing to fall back to, so the entry's provider field is
|
||||
// required rather than optional. vitest only checks that the call expression exists.
|
||||
const bare = { model_id: "glm-5.2" };
|
||||
// @ts-expect-error provider is required: a model reference is always a (provider, model_id) pair.
|
||||
const call = (): Promise<ProjectConfig> => addModel(tmpRoot, DEFAULT_PROJECT_ID, bare);
|
||||
expect(call).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it("upserts by the (provider, model_id) pair; same model_id under two providers co-exists", async () => {
|
||||
@@ -706,7 +743,10 @@ describe("project-config round trip", () => {
|
||||
(c) => c.provider === entry.provider && c.modelId === entry.model_id,
|
||||
)!;
|
||||
expect(entry.vision).toBe(cat.supportsVision ? undefined : false);
|
||||
expect(entry.pricing?.unit).toBe("usd_per_mtok");
|
||||
// A catalog entry without a list price (the Token Plan preview model) presets no
|
||||
// pricing; every other catalog entry stores USD pricing.
|
||||
if (cat.pricing === undefined) expect(entry.pricing).toBeUndefined();
|
||||
else expect(entry.pricing?.unit).toBe("usd_per_mtok");
|
||||
// A model that auto-routes leaves client_type unset; a gateway model (OpenRouter)
|
||||
// explicitly sets it to openai.
|
||||
expect(entry.client_type).toBe(cat.clientType);
|
||||
@@ -800,7 +840,7 @@ describe("project-config round trip", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveModelRef (the single "provider omitted" resolution entry point)', () => {
|
||||
describe("resolveModelRef (validates a (provider, model_id) pair against the config)", () => {
|
||||
const cfg: ProjectConfig = {
|
||||
models: [
|
||||
{ provider: "deepseek", model_id: "deepseek-v4-pro" },
|
||||
@@ -809,37 +849,44 @@ describe('resolveModelRef (the single "provider omitted" resolution entry point)
|
||||
],
|
||||
};
|
||||
|
||||
it("resolves a globally unique model_id without provider (exact match only)", () => {
|
||||
expect(resolveModelRef(cfg, "deepseek-v4-pro")).toEqual({
|
||||
it("returns the pair when it names a configured entry", () => {
|
||||
expect(resolveModelRef(cfg, "deepseek-v4-pro", "deepseek")).toEqual({
|
||||
provider: "deepseek",
|
||||
model_id: "deepseek-v4-pro",
|
||||
});
|
||||
// Exact-match lookup, no fuzzy/prefix matching.
|
||||
expect(() => resolveModelRef(cfg, "deepseek-v4")).toThrow(/is not in the Project config/);
|
||||
});
|
||||
|
||||
it("validates the exact pair when provider is given", () => {
|
||||
// The same bare model_id under two groups is never ambiguous: each pair names its own entry.
|
||||
expect(resolveModelRef(cfg, "shared-id", "siliconflow")).toEqual({
|
||||
provider: "siliconflow",
|
||||
model_id: "shared-id",
|
||||
});
|
||||
// A wrong provider grouping likewise fails: the error includes the pair reference.
|
||||
expect(resolveModelRef(cfg, "shared-id", "openrouter")).toEqual({
|
||||
provider: "openrouter",
|
||||
model_id: "shared-id",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when the pair is not configured; the error carries the pair reference", () => {
|
||||
// Wrong group for a configured model_id: no fallback to "the entry that happens to have
|
||||
// this id" — a pair the config doesn't have simply isn't a model.
|
||||
expect(() => resolveModelRef(cfg, "shared-id", "openai")).toThrow(
|
||||
/\(provider=openai, model_id=shared-id\)/,
|
||||
/is not in the Project config.*\(provider=openai, model_id=shared-id\)/,
|
||||
);
|
||||
// Unknown model_id, and exact matching only (no fuzzy/prefix matching).
|
||||
expect(() => resolveModelRef(cfg, "no-such-model", "deepseek")).toThrow(
|
||||
/\(provider=deepseek, model_id=no-such-model\)/,
|
||||
);
|
||||
expect(() => resolveModelRef(cfg, "deepseek-v4", "deepseek")).toThrow(
|
||||
/is not in the Project config/,
|
||||
);
|
||||
});
|
||||
|
||||
it("errors clearly on zero matches", () => {
|
||||
expect(() => resolveModelRef(cfg, "no-such-model")).toThrow(
|
||||
/is not in the Project config.*no-such-model/,
|
||||
);
|
||||
});
|
||||
|
||||
it("errors on ambiguity, listing the candidate pair references", () => {
|
||||
expect(() => resolveModelRef(cfg, "shared-id")).toThrow(/Ambiguous/);
|
||||
expect(() => resolveModelRef(cfg, "shared-id")).toThrow(
|
||||
/\(provider=siliconflow, model_id=shared-id\).*\(provider=openrouter, model_id=shared-id\)/,
|
||||
);
|
||||
it("requires provider: a bare model_id does not type-check (no resolution path left)", () => {
|
||||
// The pair is enforced by the type checker — asserted by `pnpm typecheck`, which includes
|
||||
// this file; the unused-directive error is the failure mode if the parameter ever goes
|
||||
// optional again. vitest only checks that the call expression exists.
|
||||
// @ts-expect-error provider is required: a model reference is always a (provider, model_id) pair.
|
||||
const call = (): ModelRef => resolveModelRef(cfg, "deepseek-v4-pro");
|
||||
expect(call).toBeTypeOf("function");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ type RunInput = { prompt: string; signal?: AbortSignal; approve?: ApproveFn };
|
||||
/** Builds a SubagentRunner from a run implementation (spawn arguments observed via a spy). */
|
||||
function runnerOf(
|
||||
run: (input: RunInput) => AsyncGenerator<OmniMessage>,
|
||||
spawnSpy?: (input: { agentId?: string; modelId?: string }) => void,
|
||||
spawnSpy?: (input: { agentId?: string; modelId?: string; provider?: string }) => void,
|
||||
): SubagentRunner {
|
||||
return {
|
||||
async spawn(input) {
|
||||
@@ -136,7 +136,8 @@ afterEach(() => {
|
||||
|
||||
describe("run_subagent tool (foreground)", () => {
|
||||
it("forwards stamped child messages and mirrors child text as its own output deltas", async () => {
|
||||
const seen: Array<{ prompt?: string; agentId?: string; modelId?: string }> = [];
|
||||
const seen: Array<{ prompt?: string; agentId?: string; modelId?: string; provider?: string }> =
|
||||
[];
|
||||
const runner = runnerOf(
|
||||
async function* (input) {
|
||||
seen[0] = { ...seen[0], prompt: input.prompt };
|
||||
@@ -150,7 +151,10 @@ describe("run_subagent tool (foreground)", () => {
|
||||
const { services } = makeServices(runner);
|
||||
const tool = createSubagentTool(DEF, services);
|
||||
const { out, result } = await collectWithReturn(
|
||||
tool.execute({ prompt: "world", agent_id: "researcher", model_id: "m1" }, CTX),
|
||||
tool.execute(
|
||||
{ prompt: "world", agent_id: "researcher", model_id: "m1", provider: "p1" },
|
||||
CTX,
|
||||
),
|
||||
);
|
||||
|
||||
// Child session messages pass through verbatim (with origin).
|
||||
@@ -163,7 +167,49 @@ describe("run_subagent tool (foreground)", () => {
|
||||
expect(result?.stopReason).toBe("completed");
|
||||
// The model is free to choose the agent and model (spawn arguments); the prompt is
|
||||
// handed to run.
|
||||
expect(seen[0]).toEqual({ prompt: "world", agentId: "researcher", modelId: "m1" });
|
||||
expect(seen[0]).toEqual({
|
||||
prompt: "world",
|
||||
agentId: "researcher",
|
||||
modelId: "m1",
|
||||
provider: "p1",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects half a model reference in either direction, and never spawns", async () => {
|
||||
// A model is referenced by the (provider, model_id) pair; the tool refuses half of one
|
||||
// rather than letting the session layer guess a group for a bare upstream id.
|
||||
for (const args of [
|
||||
{ prompt: "x", model_id: "m1" },
|
||||
{ prompt: "x", provider: "p1" },
|
||||
]) {
|
||||
const spawned: unknown[] = [];
|
||||
const runner = runnerOf(
|
||||
async function* () {},
|
||||
(input) => spawned.push(input),
|
||||
);
|
||||
const { services } = makeServices(runner);
|
||||
const tool = createSubagentTool(DEF, services);
|
||||
const { out, result } = await collectWithReturn(tool.execute(args, CTX));
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(ownDeltas(out)).toContain("must be given together");
|
||||
expect(spawned).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the Project default model when neither half of the reference is given", async () => {
|
||||
const seen: Array<{ modelId?: string; provider?: string }> = [];
|
||||
const runner = runnerOf(
|
||||
async function* () {
|
||||
yield withOrigin(partialText("delta", "ok"), HOP);
|
||||
},
|
||||
(input) => seen.push(input),
|
||||
);
|
||||
const { services } = makeServices(runner);
|
||||
const tool = createSubagentTool(DEF, services);
|
||||
const { result } = await collectWithReturn(tool.execute({ prompt: "x" }, CTX));
|
||||
expect(result?.stopReason).toBe("completed");
|
||||
expect(seen[0]?.modelId).toBeUndefined();
|
||||
expect(seen[0]?.provider).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not mirror deeper-nested (origin.length > 1) text into its own output", async () => {
|
||||
|
||||
Reference in New Issue
Block a user