diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 5bec5412..ad69c438 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -2913,6 +2913,24 @@ export const MODELS = { contextWindow: 400000, maxTokens: 128000, } satisfies Model<"openai-responses">, + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + api: "openai-responses", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, "grok-code-fast-1": { id: "grok-code-fast-1", name: "Grok Code Fast 1", @@ -7598,7 +7616,7 @@ export const MODELS = { cost: { input: 0.27, output: 0.95, - cacheRead: 0.0299999997, + cacheRead: 0.0290000007, cacheWrite: 0, }, contextWindow: 196608, @@ -9398,13 +9416,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0, - output: 0, - cacheRead: 0, + input: 0.11, + output: 0.6, + cacheRead: 0.055, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 4096, + contextWindow: 262144, + maxTokens: 262144, } satisfies Model<"openai-completions">, "qwen/qwen3-30b-a3b": { id: "qwen/qwen3-30b-a3b", @@ -10401,9 +10419,9 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.3, - output: 1.4, - cacheRead: 0.15, + input: 0.38, + output: 1.9800000000000002, + cacheRead: 0.19, cacheWrite: 0, }, contextWindow: 202752, @@ -11448,6 +11466,23 @@ export const MODELS = { contextWindow: 204800, maxTokens: 131000, } satisfies Model<"anthropic-messages">, + "minimax/minimax-m2.5-highspeed": { + id: "minimax/minimax-m2.5-highspeed", + name: "MiniMax M2.5 High Speed", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.6, + output: 2.4, + cacheRead: 0.03, + cacheWrite: 0.375, + }, + contextWindow: 4096, + maxTokens: 4096, + } satisfies Model<"anthropic-messages">, "mistral/codestral": { id: "mistral/codestral", name: "Mistral Codestral", diff --git a/packages/coding-agent/src/core/gateway-runtime.ts b/packages/coding-agent/src/core/gateway-runtime.ts index cc5149a0..55e069c4 100644 --- a/packages/coding-agent/src/core/gateway-runtime.ts +++ b/packages/coding-agent/src/core/gateway-runtime.ts @@ -1,9 +1,11 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { join } from "node:path"; import { URL } from "node:url"; +import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { ImageContent } from "@mariozechner/pi-ai"; import type { AgentSession, AgentSessionEvent } from "./agent-session.js"; import { SessionManager } from "./session-manager.js"; +import type { Settings } from "./settings-manager.js"; import { createVercelStreamListener, errorVercelStream, @@ -51,6 +53,35 @@ export interface GatewaySessionSnapshot { processing: boolean; lastActiveAt: number; createdAt: number; + name?: string; + lastMessagePreview?: string; + updatedAt: number; +} + +export interface ModelInfo { + provider: string; + modelId: string; + displayName: string; + capabilities?: string[]; +} + +export interface HistoryMessage { + id: string; + role: "user" | "assistant" | "toolResult"; + parts: HistoryPart[]; + timestamp: number; +} + +export type HistoryPart = + | { type: "text"; text: string } + | { type: "reasoning"; text: string } + | { type: "tool-invocation"; toolCallId: string; toolName: string; args: unknown; state: string; result?: unknown }; + +export interface ChannelStatus { + id: string; + name: string; + connected: boolean; + error?: string; } export interface GatewayRuntimeOptions { @@ -101,6 +132,15 @@ interface ManagedGatewaySession { unsubscribe: () => void; } +class HttpError extends Error { + constructor( + public readonly statusCode: number, + message: string, + ) { + super(message); + } +} + let activeGatewayRuntime: GatewayRuntime | null = null; export function setActiveGatewayRuntime(runtime: GatewayRuntime | null): void { @@ -122,13 +162,22 @@ export class GatewayRuntime { private server: Server | null = null; private idleSweepTimer: NodeJS.Timeout | null = null; private ready = false; + private logBuffer: string[] = []; + private readonly maxLogBuffer = 1000; constructor(options: GatewayRuntimeOptions) { this.config = options.config; this.primarySessionKey = options.primarySessionKey; this.primarySession = options.primarySession; this.createSession = options.createSession; - this.log = options.log ?? (() => {}); + const originalLog = options.log; + this.log = (msg: string) => { + this.logBuffer.push(msg); + if (this.logBuffer.length > this.maxLogBuffer) { + this.logBuffer = this.logBuffer.slice(-this.maxLogBuffer); + } + originalLog?.(msg); + }; this.sessionDirRoot = join(options.primarySession.sessionManager.getSessionDir(), "..", "gateway-sessions"); } @@ -139,7 +188,10 @@ export class GatewayRuntime { this.server = createServer((request, response) => { void this.handleHttpRequest(request, response).catch((error) => { const message = error instanceof Error ? error.message : String(error); - this.writeJson(response, 500, { error: message }); + const statusCode = error instanceof HttpError ? error.statusCode : 500; + if (!response.writableEnded) { + this.writeJson(response, statusCode, { error: message }); + } }); }); @@ -253,18 +305,20 @@ export class GatewayRuntime { const managedSession = this.sessions.get(sessionKey); if (!managedSession) return; + if (managedSession.processing) { + await managedSession.session.abort(); + } + if (sessionKey === this.primarySessionKey) { + this.rejectQueuedMessages(managedSession, "Session reset"); await managedSession.session.newSession(); - managedSession.queue.length = 0; managedSession.processing = false; managedSession.lastActiveAt = Date.now(); this.emitState(managedSession); return; } - if (managedSession.processing) { - await managedSession.session.abort(); - } + this.rejectQueuedMessages(managedSession, "Session reset"); managedSession.unsubscribe(); managedSession.session.dispose(); this.sessions.delete(sessionKey); @@ -353,6 +407,26 @@ export class GatewayRuntime { } } + private getManagedSessionOrThrow(sessionKey: string): ManagedGatewaySession { + const managedSession = this.sessions.get(sessionKey); + if (!managedSession) { + throw new HttpError(404, `Session not found: ${sessionKey}`); + } + return managedSession; + } + + private rejectQueuedMessages(managedSession: ManagedGatewaySession, error: string): void { + const queuedMessages = managedSession.queue.splice(0); + for (const queuedMessage of queuedMessages) { + queuedMessage.resolve({ + ok: false, + response: "", + error, + sessionKey: managedSession.sessionKey, + }); + } + } + private handleSessionEvent(managedSession: ManagedGatewaySession, event: AgentSessionEvent): void { switch (event.type) { case "turn_start": @@ -443,14 +517,40 @@ export class GatewayRuntime { } private createSnapshot(managedSession: ManagedGatewaySession): GatewaySessionSnapshot { + const messages = managedSession.session.messages; + let lastMessagePreview: string | undefined; + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role === "user" || msg.role === "assistant") { + const content = (msg as { content: unknown }).content; + if (typeof content === "string" && content.length > 0) { + lastMessagePreview = content.slice(0, 120); + break; + } + if (Array.isArray(content)) { + for (const part of content) { + if (typeof part === "object" && part !== null && (part as { type: string }).type === "text") { + const text = (part as { text: string }).text; + if (text.length > 0) { + lastMessagePreview = text.slice(0, 120); + break; + } + } + } + if (lastMessagePreview) break; + } + } + } return { sessionKey: managedSession.sessionKey, sessionId: managedSession.session.sessionId, - messageCount: managedSession.session.messages.length, + messageCount: messages.length, queueDepth: managedSession.queue.length, processing: managedSession.processing, lastActiveAt: managedSession.lastActiveAt, createdAt: managedSession.createdAt, + updatedAt: managedSession.lastActiveAt, + lastMessagePreview, }; } @@ -509,7 +609,40 @@ export class GatewayRuntime { return; } - const sessionMatch = path.match(/^\/sessions\/([^/]+)(?:\/(events|messages|abort|reset|chat))?$/); + if (method === "GET" && path === "/models") { + const models = await this.handleGetModels(); + this.writeJson(response, 200, models); + return; + } + + if (method === "GET" && path === "/config") { + const config = this.getPublicConfig(); + this.writeJson(response, 200, config); + return; + } + + if (method === "PATCH" && path === "/config") { + const body = await this.readJsonBody(request); + await this.handlePatchConfig(body); + this.writeJson(response, 200, { ok: true }); + return; + } + + if (method === "GET" && path === "/channels/status") { + const status = this.handleGetChannelsStatus(); + this.writeJson(response, 200, { channels: status }); + return; + } + + if (method === "GET" && path === "/logs") { + const logs = this.handleGetLogs(); + this.writeJson(response, 200, { logs }); + return; + } + + const sessionMatch = path.match( + /^\/sessions\/([^/]+)(?:\/(events|messages|abort|reset|chat|history|model|reload))?$/, + ); if (!sessionMatch) { this.writeJson(response, 404, { error: "Not found" }); return; @@ -519,11 +652,24 @@ export class GatewayRuntime { const action = sessionMatch[2]; if (!action && method === "GET") { - const session = await this.ensureSession(sessionKey); + const session = this.getManagedSessionOrThrow(sessionKey); this.writeJson(response, 200, { session: this.createSnapshot(session) }); return; } + if (!action && method === "PATCH") { + const body = await this.readJsonBody(request); + await this.handlePatchSession(sessionKey, body as { name?: string }); + this.writeJson(response, 200, { ok: true }); + return; + } + + if (!action && method === "DELETE") { + await this.handleDeleteSession(sessionKey); + this.writeJson(response, 200, { ok: true }); + return; + } + if (action === "events" && method === "GET") { await this.handleSse(sessionKey, request, response); return; @@ -551,16 +697,40 @@ export class GatewayRuntime { } if (action === "abort" && method === "POST") { + this.getManagedSessionOrThrow(sessionKey); this.writeJson(response, 200, { ok: this.abortSession(sessionKey) }); return; } if (action === "reset" && method === "POST") { + this.getManagedSessionOrThrow(sessionKey); await this.resetSession(sessionKey); this.writeJson(response, 200, { ok: true }); return; } + if (action === "history" && method === "GET") { + const limitParam = url.searchParams.get("limit"); + const messages = this.handleGetHistory(sessionKey, limitParam ? parseInt(limitParam, 10) : undefined); + this.writeJson(response, 200, { messages }); + return; + } + + if (action === "model" && method === "POST") { + const body = await this.readJsonBody(request); + const provider = typeof body.provider === "string" ? body.provider : ""; + const modelId = typeof body.modelId === "string" ? body.modelId : ""; + const result = await this.handleSetModel(sessionKey, provider, modelId); + this.writeJson(response, 200, result); + return; + } + + if (action === "reload" && method === "POST") { + await this.handleReloadSession(sessionKey); + this.writeJson(response, 200, { ok: true }); + return; + } + this.writeJson(response, 405, { error: "Method not allowed" }); } @@ -705,7 +875,11 @@ export class GatewayRuntime { return {}; } const body = Buffer.concat(chunks).toString("utf8"); - return JSON.parse(body) as Record; + try { + return JSON.parse(body) as Record; + } catch { + throw new HttpError(400, "Invalid JSON body"); + } } private writeJson(response: ServerResponse, statusCode: number, payload: unknown): void { @@ -714,6 +888,192 @@ export class GatewayRuntime { response.end(JSON.stringify(payload)); } + // --------------------------------------------------------------------------- + // New handler methods added for companion-cloud web app integration + // --------------------------------------------------------------------------- + + private async handleGetModels(): Promise<{ + models: ModelInfo[]; + current: { provider: string; modelId: string } | null; + }> { + const available = this.primarySession.modelRegistry.getAvailable(); + const models: ModelInfo[] = available.map((m) => ({ + provider: m.provider, + modelId: m.id, + displayName: m.name, + capabilities: [...(m.reasoning ? ["reasoning"] : []), ...(m.input.includes("image") ? ["vision"] : [])], + })); + const currentModel = this.primarySession.model; + const current = currentModel ? { provider: currentModel.provider, modelId: currentModel.id } : null; + return { models, current }; + } + + private async handleSetModel( + sessionKey: string, + provider: string, + modelId: string, + ): Promise<{ ok: true; model: { provider: string; modelId: string } }> { + const managed = this.getManagedSessionOrThrow(sessionKey); + const found = managed.session.modelRegistry.find(provider, modelId); + if (!found) { + throw new HttpError(404, `Model not found: ${provider}/${modelId}`); + } + await managed.session.setModel(found); + return { ok: true, model: { provider, modelId } }; + } + + private handleGetHistory(sessionKey: string, limit?: number): HistoryMessage[] { + if (limit !== undefined && (!Number.isFinite(limit) || limit < 1)) { + throw new HttpError(400, "History limit must be a positive integer"); + } + const managed = this.getManagedSessionOrThrow(sessionKey); + const rawMessages = managed.session.messages; + const messages: HistoryMessage[] = []; + for (const msg of rawMessages) { + if (msg.role !== "user" && msg.role !== "assistant" && msg.role !== "toolResult") { + continue; + } + messages.push({ + id: `${msg.timestamp}-${msg.role}`, + role: msg.role, + parts: this.messageContentToParts(msg), + timestamp: msg.timestamp, + }); + } + return limit ? messages.slice(-limit) : messages; + } + + private async handlePatchSession(sessionKey: string, patch: { name?: string }): Promise { + const managed = this.getManagedSessionOrThrow(sessionKey); + if (patch.name !== undefined) { + // Labels in pi-mono are per-entry; we label the current leaf entry + const leafId = managed.session.sessionManager.getLeafId(); + if (!leafId) { + throw new HttpError(409, `Cannot rename session without an active leaf entry: ${sessionKey}`); + } + managed.session.sessionManager.appendLabelChange(leafId, patch.name); + } + } + + private async handleDeleteSession(sessionKey: string): Promise { + if (sessionKey === this.primarySessionKey) { + throw new HttpError(400, "Cannot delete primary session"); + } + const managed = this.getManagedSessionOrThrow(sessionKey); + if (managed.processing) { + await managed.session.abort(); + } + this.rejectQueuedMessages(managed, `Session deleted: ${sessionKey}`); + managed.unsubscribe(); + managed.session.dispose(); + this.sessions.delete(sessionKey); + } + + private getPublicConfig(): Record { + const settings = this.primarySession.settingsManager.getGlobalSettings(); + const { gateway, ...rest } = settings as Record & { gateway?: Record }; + const { bearerToken: _bearerToken, webhook, ...safeGatewayRest } = gateway ?? {}; + const { secret: _secret, ...safeWebhook } = + webhook && typeof webhook === "object" ? (webhook as Record) : {}; + return { + ...rest, + gateway: { + ...safeGatewayRest, + ...(webhook && typeof webhook === "object" ? { webhook: safeWebhook } : {}), + }, + }; + } + + private async handlePatchConfig(patch: Record): Promise { + // Apply overrides on top of current settings (in-memory only for daemon use) + this.primarySession.settingsManager.applyOverrides(patch as Settings); + } + + private handleGetChannelsStatus(): ChannelStatus[] { + // Extension channel status is not currently exposed as a public API on AgentSession. + // Return empty array as a safe default. + return []; + } + + private handleGetLogs(): string[] { + return this.logBuffer.slice(-200); + } + + private async handleReloadSession(sessionKey: string): Promise { + const managed = this.getManagedSessionOrThrow(sessionKey); + // Reloading config by calling settingsManager.reload() on the session + managed.session.settingsManager.reload(); + } + + private messageContentToParts(msg: AgentMessage): HistoryPart[] { + if (msg.role === "user") { + const content = msg.content; + if (typeof content === "string") { + return [{ type: "text", text: content }]; + } + if (Array.isArray(content)) { + return content + .filter( + (c): c is { type: "text"; text: string } => typeof c === "object" && c !== null && c.type === "text", + ) + .map((c) => ({ type: "text" as const, text: c.text })); + } + return []; + } + + if (msg.role === "assistant") { + const content = msg.content; + if (!Array.isArray(content)) return []; + const parts: HistoryPart[] = []; + for (const c of content) { + if (typeof c !== "object" || c === null) continue; + if (c.type === "text") { + parts.push({ type: "text", text: (c as { type: "text"; text: string }).text }); + } else if (c.type === "thinking") { + parts.push({ type: "reasoning", text: (c as { type: "thinking"; thinking: string }).thinking }); + } else if (c.type === "toolCall") { + const tc = c as { type: "toolCall"; id: string; name: string; arguments: unknown }; + parts.push({ + type: "tool-invocation", + toolCallId: tc.id, + toolName: tc.name, + args: tc.arguments, + state: "call", + }); + } + } + return parts; + } + + if (msg.role === "toolResult") { + const tr = msg as { + role: "toolResult"; + toolCallId: string; + toolName: string; + content: unknown; + isError: boolean; + }; + const textParts = Array.isArray(tr.content) + ? (tr.content as { type: string; text?: string }[]) + .filter((c) => c.type === "text" && typeof c.text === "string") + .map((c) => c.text as string) + .join("") + : ""; + return [ + { + type: "tool-invocation", + toolCallId: tr.toolCallId, + toolName: tr.toolName, + args: undefined, + state: tr.isError ? "error" : "result", + result: textParts, + }, + ]; + } + + return []; + } + getGatewaySessionDir(sessionKey: string): string { return join(this.sessionDirRoot, sanitizeSessionKey(sessionKey)); }