feat: add verified desktop ChatGPT subscription connection

This commit is contained in:
amosblomqvist
2026-10-24 12:17:08 +03:00
parent ae2fad56c4
commit 0fc6d4ab94
53 changed files with 2557 additions and 140 deletions
+122
View File
@@ -0,0 +1,122 @@
import type { Model } from "@earendil-works/pi-ai";
import type { ChatGPTStatus } from "../core/contracts/chatgpt.ts";
import type { AdmissionScope, NoDispatchKind } from "./chatgpt-request-cycle.ts";
export type { ChatGPTStatus } from "../core/contracts/chatgpt.ts";
export interface Stamp { epoch: number; authorityRevision: number; revision: number }
interface StatusValue extends Stamp { status: ChatGPTStatus }
interface ModelsValue extends StatusValue { accountId: string | null; models: { id: string; name: string }[] }
interface LeaseValue extends Stamp { accessToken: string; accountId: string }
interface Pending { op: string; resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }
interface BridgeState {
status: ChatGPTStatus; stamp: Stamp; models: Model<"openai-responses">[]; catalogAccount: string | null;
pending: Map<string, Pending>; controllers: Map<AbortController, boolean>; listeners: Set<() => void>; counter: number; catalogRequest: number;
}
const key = Symbol.for("lh.chatgpt.bridge");
const unavailable = (): ChatGPTStatus => ({ available: false, pending: false, activeId: null, accounts: [], error: null });
const validStamp = (s: Stamp) => s && [s.epoch, s.authorityRevision, s.revision].every((v) => Number.isSafeInteger(v) && v >= 0);
function bridge(): BridgeState {
const global = globalThis as typeof globalThis & { [key]?: BridgeState };
if (!global[key]) {
const state: BridgeState = { status: unavailable(), stamp: { epoch: 0, authorityRevision: 0, revision: 0 }, models: [], catalogAccount: null, pending: new Map(), controllers: new Map(), listeners: new Set(), counter: 0, catalogRequest: 0 };
global[key] = state;
process.on("message", (raw: unknown) => {
const m = raw as { channel?: string; kind?: string; id?: string; op?: string; ok?: boolean; value?: unknown; stamp: Stamp; status: ChatGPTStatus; invalidate?: string; error?: string };
if (m?.channel !== "lh:chatgpt") return;
if (m.kind === "state" && validStamp(m.stamp) && m.stamp.revision >= state.stamp.revision) {
acceptStatus(state, { ...m.stamp, status: m.status });
if (m.invalidate === "all") { state.catalogRequest++; clearCatalog(state); }
if (m.invalidate === "all" || m.invalidate === "background") {
for (const [controller, background] of state.controllers) if (m.invalidate === "all" || background) controller.abort(m.invalidate === "all" ? "all" : "background");
}
for (const listener of state.listeners) listener();
}
if (m.id) {
const call = state.pending.get(m.id);
if (!call || call.op !== m.op) return;
clearTimeout(call.timer); state.pending.delete(m.id);
if (m.ok === true) call.resolve(m.value);
else call.reject(new Error(m.error ?? "ChatGPT connection failed."));
}
});
process.on("disconnect", () => {
state.status = unavailable(); clearCatalog(state); state.catalogRequest++;
for (const controller of state.controllers.keys()) controller.abort("all");
for (const call of state.pending.values()) { clearTimeout(call.timer); call.reject(new Error("The desktop connection closed.")); }
state.pending.clear();
for (const listener of state.listeners) listener();
});
}
return global[key];
}
function clearCatalog(state: BridgeState) { state.models = []; state.catalogAccount = null; }
function acceptStatus(state: BridgeState, value: StatusValue) {
if (!validStamp(value) || value.revision < state.stamp.revision) return;
if (value.epoch !== state.stamp.epoch || value.authorityRevision !== state.stamp.authorityRevision || value.status.activeId !== state.status.activeId) clearCatalog(state);
state.status = value.status; state.stamp = { epoch: value.epoch, authorityRevision: value.authorityRevision, revision: value.revision };
}
/** Runtime-only inherited IPC. No HTTP credential route or renderer method. */
export function chatGPTRequest<T>(op: "status" | "models" | "lease", fields: Record<string, unknown> = {}): Promise<T> {
const state = bridge();
if (!process.send || !process.connected) return Promise.reject(new Error("ChatGPT sign-in is available only in the desktop app with a private server connection."));
return new Promise<T>((resolve, reject) => {
const id = `${process.pid}:${++state.counter}`;
const timer = setTimeout(() => { state.pending.delete(id); reject(new Error("The ChatGPT desktop connection timed out.")); }, 45000);
state.pending.set(id, { op, resolve: (v) => resolve(v as T), reject, timer });
process.send!({ ...fields, channel: "lh:chatgpt", id, op }, (error) => {
if (error) { clearTimeout(timer); state.pending.delete(id); reject(new Error("The ChatGPT desktop connection closed.")); }
});
});
}
export function chatGPTStatus(): ChatGPTStatus { return bridge().status; }
export function chatGPTConnected(): boolean { const s = chatGPTStatus(); return s.available && s.accounts.some((a) => a.id === s.activeId && a.connected && a.planEnabled === true); }
export function chatGPTBackgroundAllowed(): boolean { const s = chatGPTStatus(); return chatGPTConnected() && s.accounts.some((a) => a.id === s.activeId && a.background); }
export function onChatGPTChange(listener: () => void): () => void { bridge().listeners.add(listener); return () => { bridge().listeners.delete(listener); }; }
export function chatGPTModels(): Model<"openai-responses">[] { return bridge().models; }
export function chatGPTModel(id: string): Model<"openai-responses"> | undefined { return chatGPTModels().find((m) => m.id === id); }
export function chatGPTScope(selectedModelId: string): AdmissionScope {
const state = bridge();
if (!chatGPTConnected() || !state.catalogAccount || state.catalogAccount !== state.status.activeId || !chatGPTModel(selectedModelId)) throw new Error("ChatGPT account model catalog is unavailable");
return { accountId: state.catalogAccount, epoch: state.stamp.epoch, authorityRevision: state.stamp.authorityRevision, selectedModelId };
}
export function chatGPTScopeCurrent(scope: AdmissionScope): boolean {
const s = bridge();
return !!process.connected && chatGPTConnected() && scope.accountId === s.catalogAccount && scope.accountId === s.status.activeId && scope.epoch === s.stamp.epoch && scope.authorityRevision === s.stamp.authorityRevision && !!chatGPTModel(scope.selectedModelId);
}
export async function chatGPTLease(scope: AdmissionScope, background: boolean): Promise<LeaseValue> {
if (!chatGPTScopeCurrent(scope)) throw new Error("ChatGPT catalog authority changed");
const value = await chatGPTRequest<LeaseValue>("lease", { background, expectedAccountId: scope.accountId, expectedAuthorityRevision: scope.authorityRevision, selectedModelId: scope.selectedModelId });
if (!validStamp(value) || !chatGPTScopeCurrent(scope) || value.epoch !== scope.epoch || value.authorityRevision !== scope.authorityRevision || value.accountId !== scope.accountId || typeof value.accessToken !== "string" || !value.accessToken || value.accessToken.startsWith("sk-")) throw new Error("ChatGPT lease is stale");
return value;
}
/** Safe status confirmation can establish consent-only denial, never parse error text. */
export async function chatGPTDenial(scope: AdmissionScope | undefined, controller: AbortController, callerAborted: boolean, background: boolean): Promise<NoDispatchKind> {
if (callerAborted || controller.signal.reason === "all") return "all";
if (!scope || !chatGPTScopeCurrent(scope)) return "other";
if (controller.signal.reason === "background") return "background";
if (!background) return "other";
try { const value = await chatGPTRequest<StatusValue>("status"); acceptStatus(bridge(), value); }
catch { return "other"; }
if (controller.signal.reason === "all" || !chatGPTScopeCurrent(scope)) return "all";
return !chatGPTBackgroundAllowed() ? "background" : "other";
}
/** Preserve account order; unknown capabilities/windows are never fabricated. */
export async function refreshChatGPTModels(supported: Model<"openai-responses">[]): Promise<void> {
const state = bridge();
if (!process.send || !process.connected) { clearCatalog(state); return; }
const sequence = ++state.catalogRequest;
try {
const value = await chatGPTRequest<ModelsValue>("models");
if (sequence !== state.catalogRequest || !validStamp(value)) return;
acceptStatus(state, value);
if (value.epoch !== state.stamp.epoch || value.authorityRevision !== state.stamp.authorityRevision || value.accountId !== state.status.activeId || !chatGPTConnected()) return;
state.catalogAccount = value.accountId;
state.models = value.models.flatMap((entry) => {
const known = supported.find((m) => m.id === entry.id && m.api === "openai-responses");
return known ? [{ ...known, provider: "chatgpt", name: entry.name, headers: undefined, samplingParams: undefined, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }] : [];
});
} catch { if (sequence === state.catalogRequest) clearCatalog(state); }
}
export function chatGPTAbortController(background = true): { controller: AbortController; release: () => void } {
const state = bridge(); const controller = new AbortController(); state.controllers.set(controller, background);
return { controller, release: () => { state.controllers.delete(controller); } };
}
+122
View File
@@ -0,0 +1,122 @@
import type { AgentEvent, AgentMessage, PrepareRequestContext } from "@earendil-works/pi-agent-core";
import type { AssistantMessage } from "@earendil-works/pi-ai";
import { nativeInputCause, needsContinuation, type LhMessage } from "../core/native-messages.ts";
export interface AdmissionScope { accountId: string; epoch: number; authorityRevision: number; selectedModelId: string }
export type NoDispatchKind = "background" | "all" | "other";
export interface ChatGPTDispatchLifecycle {
captured(scope: AdmissionScope): void;
beforeDispatch(scope: AdmissionScope): void;
noDispatch(kind: NoDispatchKind): void;
}
interface CycleOptions {
initialMessages: AgentMessage[];
staged?: AgentMessage[];
foregroundOwed: boolean;
purpose: { background: boolean };
scopeCurrent: (scope: AdmissionScope) => boolean;
commitStaged?: () => void;
restoreAutomatic: (automaticIds: ReadonlySet<string>, retainedIds: ReadonlySet<string>) => void;
}
const idOf = (m: AgentMessage) => (m as { id?: string }).id;
/** One logical turn, not an SDK loop. No credentials, persisted provenance or wire protocol. */
export class ChatGPTRequestCycle implements ChatGPTDispatchLifecycle {
private release?: (event: AgentEvent) => void;
private held: AgentEvent[] = [];
private snapshot: AgentMessage[] = [];
private seen: Set<string>;
private automatic = new Set<string>();
private committed = false;
private denied?: NoDispatchKind;
private scope?: AdmissionScope;
private originalScope?: AdmissionScope;
private foregroundOwed: boolean;
private inheritedBackground: boolean;
private resumed = false;
private userAborted = false;
private stagedCommitted = false;
private started = false;
private anyDispatch = false;
private firstResolved = false;
private resolveAdmission!: (admitted: boolean) => void;
readonly admission = new Promise<boolean>((resolve) => { this.resolveAdmission = resolve; });
constructor(private readonly options: CycleOptions) {
this.seen = new Set(options.initialMessages.map(idOf).filter((id): id is string => !!id));
for (const message of options.staged ?? []) { const id = idOf(message); if (id) this.seen.delete(id); }
this.foregroundOwed = options.foregroundOwed;
this.inheritedBackground = options.purpose.background;
}
get dispatched(): boolean { return this.anyDispatch; }
get deniedBeforeDispatch(): boolean { return this.denied !== undefined; }
abort(): void { this.userAborted = true; }
bindRelease(release: (event: AgentEvent) => void): () => void { this.release = release; return () => { this.release = undefined; }; }
prepare(request: PrepareRequestContext): void {
const messages = request.context.messages as AgentMessage[];
this.snapshot = [...messages]; this.automatic = new Set();
let learner = false;
for (const m of messages) {
const id = idOf(m); if (!id || this.seen.has(id)) continue;
const cause = nativeInputCause(m, messages.slice(0, messages.indexOf(m)));
if (cause === "automatic") this.automatic.add(id);
if (cause === "learner") learner = true;
}
if (this.automatic.size > 0) this.options.purpose.background = true;
const retained = messages.filter((m) => !this.automatic.has(idOf(m) ?? ""));
this.foregroundOwed ||= learner || (!this.inheritedBackground && needsContinuation(retained as LhMessage[]));
}
hold(event: AgentEvent): boolean {
if (event.type === "agent_start") { if (this.started) return true; this.started = true; return false; }
if (event.type === "agent_end") return true; // settle only after native activeRun is cleared
if (event.type === "turn_start") {
this.committed = false; this.denied = undefined; this.scope = undefined;
// A retained learner batch from the denied cycle already owns its turn_start.
if (!this.held.some((e) => e.type === "turn_start")) this.held.push(event);
return true;
}
if (!this.committed) { this.held.push(event); return true; }
return false;
}
captured(scope: AdmissionScope): void {
if (this.originalScope && this.resumed && JSON.stringify(scope) !== JSON.stringify(this.originalScope)) throw new Error("ChatGPT recovery scope changed");
this.scope = { ...scope };
this.originalScope ??= { ...scope };
}
beforeDispatch(scope: AdmissionScope): void {
if (this.userAborted || this.denied || this.committed || !this.scope || JSON.stringify(scope) !== JSON.stringify(this.scope) || !this.options.scopeCurrent(scope)) throw new Error("ChatGPT admission cancelled");
if (!this.stagedCommitted && (this.options.staged?.length ?? 0) > 0) {
this.options.commitStaged?.(); // synchronous durable prefix, before visibility and fetch
this.stagedCommitted = true;
const stagedEvents: AgentEvent[] = this.options.staged!.flatMap((message) => [{ type: "message_start" as const, message }, { type: "message_end" as const, message }]);
const boundary = this.held.findIndex((event) => event.type === "turn_start");
this.held.splice(boundary < 0 ? 0 : boundary + 1, 0, ...stagedEvents);
}
this.committed = true; this.anyDispatch = true; this.foregroundOwed = false;
this.inheritedBackground = this.options.purpose.background;
for (const m of this.snapshot) { const id = idOf(m); if (id) this.seen.add(id); }
const events = this.held; this.held = [];
for (const event of events) this.release?.(event);
this.resolveFirst(true);
}
noDispatch(kind: NoDispatchKind): void { if (!this.committed) this.denied = this.userAborted ? "all" : kind; }
acceptsUsage(_message: AssistantMessage): boolean { return this.committed; }
private resolveFirst(value: boolean) { if (!this.firstResolved) { this.firstResolved = true; this.resolveAdmission(value); } }
finishNativeCycle(): { action: "settle" | "resumeForeground"; messages: AgentMessage[] } {
if (!this.denied) { this.resolveFirst(this.anyDispatch); return { action: "settle", messages: [] }; }
const messages = this.snapshot.filter((m) => !this.automatic.has(idOf(m) ?? ""));
const retainedIds = new Set(messages.map(idOf).filter((id): id is string => !!id));
this.options.restoreAutomatic(this.automatic, retainedIds);
this.resolveFirst(false); // queue deferral is installed before initial fire cargo returns
this.held = this.held.filter((e) => (e.type === "turn_start") || ((e.type === "message_start" || e.type === "message_end") && e.message.role !== "assistant" && !this.automatic.has(idOf(e.message) ?? "")));
const retry = this.denied === "background" && !this.userAborted && !this.resumed && this.foregroundOwed && !!this.scope && this.options.scopeCurrent(this.scope);
if (retry) {
this.resumed = true; this.options.purpose.background = false;
this.seen = retainedIds;
this.denied = undefined;
// Staged automatic input must not be resurrected on foreground recovery.
this.options.staged = undefined;
return { action: "resumeForeground", messages };
}
this.held = [];
return { action: "settle", messages };
}
}
+118
View File
@@ -0,0 +1,118 @@
import type { Api, AssistantMessage, Model, SimpleStreamOptions, StreamFunction, TranscriptContext } from "@earendil-works/pi-ai";
import { streamSimple } from "@earendil-works/pi-ai/api/openai-responses";
import { AssistantMessageEventStream } from "@earendil-works/pi-ai/utils/event-stream";
import { transformMessages } from "@earendil-works/pi-ai/api/transform-messages";
import { chatGPTAbortController, chatGPTScope, chatGPTScopeCurrent, chatGPTLease, chatGPTDenial } from "./chatgpt-bridge.ts";
import type { AdmissionScope, ChatGPTDispatchLifecycle } from "./chatgpt-request-cycle.ts";
const USAGE = "https://chatgpt.com/settings/usage";
export function chatGPTStreamError(message: string): string {
if (/subscription_sharing_usage_limit_exceeded|quota|429/.test(message)) return `ChatGPT plan usage limit reached. Manage usage at ${USAGE}. No API-key fallback was used.`;
if (/scope|authorization_context|permission/.test(message)) return "ChatGPT plan permission is missing. Reconnect in Connections and authorize plan use. No API-key fallback was used.";
if (/expired|invalid_grant|401|invalid_user/.test(message)) return "Your ChatGPT session expired. Reconnect in Connections. No API-key fallback was used.";
if (/background|Background/.test(message)) return "Background use is off for this ChatGPT account. Enable Allow background use in Connections to run this work. No API-key fallback was used.";
if (/eligible|403/.test(message)) return "This ChatGPT account or workspace does not permit this request. Check ChatGPT settings; reconnecting may not change eligibility.";
if (/cancel|abort|closed|disconnect/i.test(message)) return "ChatGPT request cancelled or connection closed. No API-key fallback was used.";
if (/catalog|model/.test(message)) return "The selected model is unavailable for this ChatGPT account. Refresh Connections and explicitly choose an available model. No API-key fallback was used.";
if (/incomplete|terminal|interrupted|stop reason/.test(message)) return "ChatGPT returned an incomplete response. Retry when ready; no API-key fallback was used.";
return "ChatGPT request failed. Check your connection and ChatGPT usage, then retry. No API-key fallback was used.";
}
/** Restrict the body at the final payload seam, even if caller samplingParams add fields. */
export function chatGPTPayload(raw: unknown, selectedModelId: string): Record<string, unknown> {
const payload = raw as Record<string, unknown>;
const out: Record<string, unknown> = { model: selectedModelId, store: false, stream: true };
for (const key of ["instructions", "reasoning", "include", "text", "tool_choice", "parallel_tool_calls", "prompt_cache_key"]) {
if (payload[key] !== undefined) out[key] = payload[key];
}
if (!Array.isArray(payload.input)) throw new Error("ChatGPT requires stateless input history");
out.input = payload.input.map((item) => item?.role === "system" ? { ...item, role: "developer" } : item);
if (Array.isArray(payload.tools) && payload.tools.length) {
if (payload.tools.some((tool) => tool.type !== "function" && tool.type !== "custom")) throw new Error("Unsupported ChatGPT tool capability");
out.tools = [{ type: "namespace", name: "learnharness", description: "LearnHarness local learning tools", tools: payload.tools }];
}
return out;
}
/** Only ChatGPT history is translated. Mixed API-key history keeps its actual identity. */
export function chatGPTHistory(context: TranscriptContext, logicalModel: Model<Api>): TranscriptContext {
// Apply Pi's normal foreign-model signature treatment BEFORE the alias
// becomes openai, or real API-key turns could look like subscription turns.
const messages = transformMessages(context.messages, logicalModel);
return { ...context, messages: messages.map((m) => m.role === "assistant" && m.provider === "chatgpt" ? { ...m, provider: "openai" } : m) };
}
function empty(model: Model<Api>): AssistantMessage {
return { role: "assistant", api: model.api, provider: "chatgpt", model: model.id, content: [], stopReason: "error", timestamp: Date.now(), usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
}
export function chatGPTStream(background: () => boolean, lifecycle?: ChatGPTDispatchLifecycle): StreamFunction<Api, SimpleStreamOptions> {
return (logicalModel, context, options) => {
const output = new AssistantMessageEventStream();
const purpose = background();
const { controller, release } = chatGPTAbortController(purpose);
const signal = options?.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
void (async () => {
let partial = empty(logicalModel);
let completed = false;
let failure = "";
let scope: AdmissionScope | undefined;
let committed = false;
let terminal = false;
const check = () => { if (signal.aborted || !scope || !chatGPTScopeCurrent(scope)) throw new Error("cancelled or catalog authority changed"); };
try {
if (logicalModel.provider !== "chatgpt") throw new Error("ChatGPT account catalog has no selected model");
if (Object.keys(options?.headers ?? {}).some((key) => key.toLowerCase() === "authorization")) throw new Error("Authorization override is forbidden");
scope = chatGPTScope(logicalModel.id);
lifecycle?.captured(scope);
const credentials = await chatGPTLease(scope, purpose);
check();
// NEVER dispatch through Models under openai: that would expose ambient API-key fallback.
const transport: Model<"openai-responses"> = { ...logicalModel, api: "openai-responses", provider: "openai", baseUrl: "https://api.openai.com/v1", headers: undefined, samplingParams: undefined, compat: { ...logicalModel.compat, supportsDeveloperRole: true } };
const delegate = options?.fetch ?? globalThis.fetch;
const source = streamSimple(transport, chatGPTHistory(context, logicalModel), {
...options,
apiKey: credentials.accessToken,
signal,
maxRetries: 0,
headers: { "OpenAI-Beta": null, ...options?.headers },
fetch: (input, init) => {
check();
if (committed) throw new Error("ChatGPT does not retry admitted requests");
lifecycle?.beforeDispatch(scope!);
committed = true;
return delegate(input, init);
},
onPayload: async (raw, model) => {
const replacement = await options?.onPayload?.(raw, model);
const payload = chatGPTPayload(replacement === undefined ? raw : replacement, logicalModel.id);
check();
return payload;
},
onProviderStreamEvent: async (raw, model) => {
const event = raw as { type?: string; code?: string; error?: { code?: string }; response?: { error?: { code?: string }; status?: string } };
if (event.type === "response.completed" && event.response?.status === "completed" && !event.response?.error) completed = true;
if (["response.incomplete", "response.failed", "error"].includes(event.type ?? "") || event.response?.error) failure = event.code || event.error?.code || event.response?.error?.code || "incomplete terminal response";
await options?.onProviderStreamEvent?.(raw, model);
},
});
for await (const event of source) {
const message = "partial" in event ? event.partial : event.type === "done" ? event.message : event.error;
// Copy the message, not its tool/reasoning blocks: IDs/signatures remain byte-for-byte intact.
partial = { ...message, provider: "chatgpt" };
if (event.type === "done") {
if (!completed || failure) throw new Error(failure || "missing terminal response.completed");
terminal = true;
output.push({ ...event, message: partial });
} else if (event.type === "error") {
throw new Error(failure || event.error.errorMessage || "ChatGPT stream error");
} else output.push({ ...event, partial });
}
} catch (err) {
if (terminal) return;
if (!committed) lifecycle?.noDispatch(await chatGPTDenial(scope, controller, options?.signal?.aborted === true, purpose));
partial = { ...partial, stopReason: signal.aborted ? "aborted" : "error", errorMessage: chatGPTStreamError(err instanceof Error ? err.message : "") };
output.push({ type: "error", reason: partial.stopReason as "error" | "aborted", error: partial });
} finally { release(); output.end(); }
})();
return output;
};
}
+9 -7
View File
@@ -12,6 +12,9 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { type ModelRole } from "../../config.ts";
import { DEFAULT_THINKING_ORDER } from "../core/settings.ts";
import packageMetadata from "../../package.json" with { type: "json" };
import { chatGPTModel } from "./chatgpt-bridge.ts";
import { chatGPTStream } from "./chatgpt-stream.ts";
import type { ChatGPTDispatchLifecycle } from "./chatgpt-request-cycle.ts";
/** App identity is immutable and shared by Models and every native Agent.
* Agent's default Pi attribution otherwise overrides the Models identity. */
@@ -93,9 +96,9 @@ function defaultThinkingLevel(model: Model<Api>): ThinkingLevel {
* key is resolved by pi-ai itself from the environment per request (the
* keys seam reflects file-saved keys into env), so the engine never
* hardcodes OpenRouter's. */
export function piEngineForSpec(spec: ResolvedModelSpec): PiEngine {
export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | (() => boolean) = true, lifecycle?: ChatGPTDispatchLifecycle): PiEngine {
const models: Models = piModels();
const model = models.getModel(spec.provider, spec.id);
const model = spec.provider === "chatgpt" ? chatGPTModel(spec.id) : models.getModel(spec.provider, spec.id);
if (!model) {
throw new Error(`${spec.provider} catalog has no model ${spec.id}`);
}
@@ -111,11 +114,10 @@ export function piEngineForSpec(spec: ResolvedModelSpec): PiEngine {
// bound streamSimple: the agent loop forwards state.thinkingLevel as
// `reasoning` (undefined = off on pi-ai's scale); the clamp above keeps
// the pin honest even when config or settings drift off the map.
streamFn: (requestModel, context, options) =>
models.streamSimple(requestModel, context, {
...options,
reasoning: level === "off" ? undefined : level,
}),
streamFn: (requestModel, context, options) => {
const stream = spec.provider === "chatgpt" ? chatGPTStream(typeof background === "function" ? background : () => background, lifecycle) : models.streamSimple.bind(models);
return stream(requestModel, context, { ...options, reasoning: level === "off" ? undefined : level });
},
};
}
+3
View File
@@ -137,6 +137,9 @@ export function browserServiceSpawnEnv(
const out: Record<string, string | undefined> = { ...env };
delete out.ELECTRON_RUN_AS_NODE;
delete out.LH_DEV_URL;
// The artifact renderer is not an authorized app API client.
delete out.LH_DESKTOP_API_SECRET;
delete out.LH_DESKTOP_SERVER;
return out;
}
+24 -2
View File
@@ -18,7 +18,8 @@ import { practiceTools, type PracticeToolContext } from "./tools/practice.ts";
import { healToolRecord } from "./tools/heal.ts";
import { quizInputObject, quizInputProblems } from "../core/question-core.ts";
import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts";
import { toLlmMessages } from "../core/native-messages.ts";
import { toLlmMessages, nativeInputCause } from "../core/native-messages.ts";
import type { ChatGPTRequestCycle } from "./chatgpt-request-cycle.ts";
/**
* The two teaching-side agents (docs/probe-teach-split.md): the PROBER and
@@ -97,6 +98,8 @@ export type StepUsageCollector = (usage: Usage) => void;
export interface AgentBuildOptions {
/** Model + stream function — tests inject a scripted faux engine. */
engine?: PiEngine;
callPurpose?: { background: boolean };
requestCycle?: ChatGPTRequestCycle;
/** The initial transcript, as pi messages (the record IS agent state). */
messages?: AgentMessage[];
/** Money channel: one call per model request. */
@@ -178,6 +181,7 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
// LH_PROVIDER_EVENTS=1 and the caller handed a chatId; undefined is
// the normal no-observer path (tests, env-off).
const providerEvents = opts.chatId ? providerEventObserver(opts.chatId, kind) : undefined;
const seenInputs = new Set((opts.messages ?? []).map((m) => (m as { id?: string }).id).filter(Boolean));
const agent = new Agent({
identity: APP_IDENTITY,
sessionId: opts.chatId,
@@ -211,13 +215,31 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
// an abort's dangling question call gets its INTERRUPTED result. It
// must never throw (pi's convertToLlm contract).
convertToLlm: toLlmMessages,
prepareRequest: (request) => {
if (opts.callPurpose) {
let learner = false, automatic = false;
const messages = request.context.messages;
for (const message of messages) {
const id = (message as { id?: string }).id;
const cause = nativeInputCause(message, messages.slice(0, messages.indexOf(message)));
if (cause === "neutral") continue;
if (id && seenInputs.has(id)) continue;
if (id) seenInputs.add(id);
if (cause === "learner" && id) learner = true;
else automatic = true; // missing/unrecognized identity never grants foreground
}
if (automatic) opts.callPurpose.background = true;
else if (learner) opts.callPurpose.background = false;
}
opts.requestCycle?.prepare(request);
},
});
if (opts.onUsage) {
agent.subscribe((event: AgentEvent) => {
if (event.type === "message_end" && event.message.role === "assistant" && "usage" in event.message) {
// A cancelled call is not a billed step (partial/estimated
// usage on an aborted message is not money spent).
if (event.message.stopReason === "aborted") return;
if (event.message.stopReason === "aborted" || (opts.requestCycle && !opts.requestCycle.acceptsUsage(event.message))) return;
opts.onUsage!(event.message.usage);
}
});