feat: add app-owned Copilot and Grok subscription connections
This commit is contained in:
+17
-15
@@ -7,9 +7,10 @@ import {
|
||||
type TeachingAgent,
|
||||
} from "../agent/teacher-agent.ts";
|
||||
import { piEngineForSpec, type PiEngine } from "../agent/models.ts";
|
||||
import { chatGPTBackgroundAllowed, chatGPTScopeCurrent } from "../agent/chatgpt-bridge.ts";
|
||||
import { connectionBackgroundAllowed, connectionScopeCurrent, isSubscriptionProvider } from "../agent/subscription-connections.ts";
|
||||
import { nativeSubscriptionError } from "../agent/subscription-stream.ts";
|
||||
import { chatGPTStreamError } from "../agent/chatgpt-stream.ts";
|
||||
import { ChatGPTRequestCycle, type ChatGPTDispatchLifecycle } from "../agent/chatgpt-request-cycle.ts";
|
||||
import { SubscriptionRequestCycle, type SubscriptionDispatchLifecycle } from "../agent/subscription-request-cycle.ts";
|
||||
import { reconstructRequestCause } from "../core/native-messages.ts";
|
||||
import { createJobRunner } from "../agent/subagents/runner.ts";
|
||||
import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
|
||||
@@ -206,10 +207,10 @@ export async function handleChatInput(
|
||||
// persist (nothing durable changes); wake-pipeline turns ride the same
|
||||
// path. The affected tier is strong — the conversation chain's own tier
|
||||
// (a chat.model override still resolves through it).
|
||||
if (conversation.provider === "chatgpt") {
|
||||
await refreshConnectionCatalog();
|
||||
if (input.type === "steers" && !chatGPTBackgroundAllowed()) {
|
||||
return Response.json({ error: "ChatGPT background use is off in this app. Automatic follow-up remains deferred in its queue." }, { status: 403 });
|
||||
if (isSubscriptionProvider(conversation.provider)) {
|
||||
await refreshConnectionCatalog(conversation.provider);
|
||||
if (input.type === "steers" && !connectionBackgroundAllowed(conversation.provider)) {
|
||||
return Response.json({ error: "Background use is off for this connection. Automatic follow-up remains deferred in its queue." }, { status: 403 });
|
||||
}
|
||||
}
|
||||
if (!providerKeyPresent(conversation.provider)) {
|
||||
@@ -228,21 +229,21 @@ export async function handleChatInput(
|
||||
// beginTurn, no liveness effects — and the record re-reads after
|
||||
// because compaction appends the marker.
|
||||
const callPurpose = { background: input.type === "steers" || (input.type === "continue" && reconstructRequestCause(record.messages) !== "foreground") };
|
||||
let requestCycle: ChatGPTRequestCycle | undefined;
|
||||
const lifecycle: ChatGPTDispatchLifecycle = {
|
||||
let requestCycle: SubscriptionRequestCycle | undefined;
|
||||
const lifecycle: SubscriptionDispatchLifecycle = {
|
||||
captured: (scope) => requestCycle?.captured(scope),
|
||||
beforeDispatch: (scope) => requestCycle?.beforeDispatch(scope),
|
||||
noDispatch: (kind) => requestCycle?.noDispatch(kind),
|
||||
};
|
||||
let conversationEngine: PiEngine;
|
||||
try { conversationEngine = piEngineForSpec(conversation, () => callPurpose.background, lifecycle); }
|
||||
catch { return Response.json({ error: conversation.provider === "chatgpt" ? chatGPTStreamError("model catalog") : "The selected model is unavailable." }, { status: 503 }); }
|
||||
catch { return Response.json({ error: conversation.provider === "chatgpt" ? chatGPTStreamError("model catalog") : isSubscriptionProvider(conversation.provider) ? nativeSubscriptionError(conversation.provider, "model catalog") : "The selected model is unavailable." }, { status: 503 }); }
|
||||
const modelIdForCompaction = `${conversationEngine.model.provider}:${conversationEngine.model.id}`;
|
||||
await ensureCompacted(
|
||||
{
|
||||
chatStore: stores.chatStore,
|
||||
engine: piEngineForSpec(conversation, true),
|
||||
canStart: () => conversation.provider !== "chatgpt" || chatGPTBackgroundAllowed(),
|
||||
canStart: () => connectionBackgroundAllowed(conversation.provider),
|
||||
modelId: modelIdForCompaction,
|
||||
kind: record.kind === "practice" ? "practice" : hasProbeDone(record.messages) ? "teach" : "probe",
|
||||
thresholdTokens: effectiveThresholdTokens(
|
||||
@@ -308,7 +309,7 @@ export async function handleChatInput(
|
||||
// ride this same write-ahead - the pump de-queued them at fire, so this
|
||||
// is the ONLY copy ever appended.)
|
||||
const merged = [...record.messages, ...appends];
|
||||
const stagedAutomatic = conversation.provider === "chatgpt" && input.type === "steers";
|
||||
const stagedAutomatic = isSubscriptionProvider(conversation.provider) && input.type === "steers";
|
||||
let persistBaseCount = stagedAutomatic ? record.messages.length : merged.length;
|
||||
try {
|
||||
if (!stagedAutomatic) stores.chatStore.saveMessages(chatId, merged);
|
||||
@@ -363,12 +364,12 @@ export async function handleChatInput(
|
||||
// enabled). The definitive toggleable set is TOGGLEABLE_TOOLS — the
|
||||
// sub-agent JOB tools (research/demo/visual/pdf; adjudicated with the
|
||||
// user: quiz/ask/fs/plan/probe_done are always-on).
|
||||
if (conversation.provider === "chatgpt") requestCycle = new ChatGPTRequestCycle({
|
||||
if (isSubscriptionProvider(conversation.provider)) requestCycle = new SubscriptionRequestCycle({
|
||||
initialMessages: asAgentMessages(merged),
|
||||
staged: stagedAutomatic ? asAgentMessages(appends) : undefined,
|
||||
foregroundOwed: !callPurpose.background,
|
||||
purpose: callPurpose,
|
||||
scopeCurrent: chatGPTScopeCurrent,
|
||||
scopeCurrent: (scope) => connectionScopeCurrent(conversation.provider, scope),
|
||||
commitStaged: () => { stores.chatStore.saveMessages(chatId, merged); persistBaseCount = merged.length; },
|
||||
restoreAutomatic: (automaticIds, retainedIds) => restoreUncommittedAutomatic(chatId, constructed, automaticIds, retainedIds),
|
||||
});
|
||||
@@ -491,6 +492,7 @@ export async function handleChatInput(
|
||||
// transcript directly (the retry context the model saw).
|
||||
onError: (error) => {
|
||||
if (conversation.provider === "chatgpt") return chatGPTStreamError(String(error));
|
||||
if (isSubscriptionProvider(conversation.provider)) return nativeSubscriptionError(conversation.provider, String(error));
|
||||
console.error(`[chat ${chatId}] turn error:`, error);
|
||||
return "An error occurred.";
|
||||
},
|
||||
@@ -561,7 +563,7 @@ export async function handleChatInput(
|
||||
kickCompaction(() => {
|
||||
const fresh = effectiveSettings();
|
||||
const spec = resolveConversationSpec(stores.chatStore.getChat(chatId)?.settings, fresh.models.strong);
|
||||
if (!spec || (spec.provider === "chatgpt" && !chatGPTBackgroundAllowed())) return null;
|
||||
if (!spec || !connectionBackgroundAllowed(spec.provider)) return null;
|
||||
let kickEngine: PiEngine;
|
||||
try { kickEngine = piEngineForSpec(spec, true); } catch { return null; }
|
||||
const kickModelId = `${kickEngine.model.provider}:${kickEngine.model.id}`;
|
||||
@@ -570,7 +572,7 @@ export async function handleChatInput(
|
||||
return {
|
||||
chatStore: stores.chatStore,
|
||||
engine: kickEngine,
|
||||
canStart: () => spec.provider !== "chatgpt" || chatGPTBackgroundAllowed(),
|
||||
canStart: () => connectionBackgroundAllowed(spec.provider),
|
||||
modelId: kickModelId,
|
||||
kind: freshRecord.kind === "practice" ? "practice" : hasProbeDone(freshRecord.messages) ? "teach" : "probe",
|
||||
thresholdTokens: effectiveThresholdTokens(
|
||||
|
||||
+4
-4
@@ -68,7 +68,7 @@ import { danglingQuestionOf, endsInDismissal, type LhMessage } from "../core/nat
|
||||
import { fireJobAbort, isJobLive } from "../agent/subagents/aborts.ts";
|
||||
import { stores, effectiveSettings } from "./stores.ts";
|
||||
import { resolveConversationSpec } from "../core/settings-resolution.ts";
|
||||
import { chatGPTBackgroundAllowed, onChatGPTChange } from "../agent/chatgpt-bridge.ts";
|
||||
import { connectionBackgroundAllowed, isSubscriptionProvider, onSubscriptionChange } from "../agent/subscription-connections.ts";
|
||||
import { isTurnRunning } from "./turn-liveness.ts";
|
||||
import type { Agent } from "@earendil-works/pi-agent-core";
|
||||
|
||||
@@ -217,7 +217,7 @@ export function deliverJobResult(steer: SteerMessage): Promise<void> {
|
||||
export function automaticFollowUpAllowed(chatId: string, agent?: Agent): boolean {
|
||||
if (agent && state().automaticDeferred.has(agent)) return false;
|
||||
const provider = agent?.state?.model?.provider ?? resolveConversationSpec(stores.chatStore.getChat(chatId)?.settings, effectiveSettings().models.strong)?.provider;
|
||||
return provider !== "chatgpt" || chatGPTBackgroundAllowed();
|
||||
return connectionBackgroundAllowed(provider);
|
||||
}
|
||||
|
||||
function handOffEligible(chatId: string, agent: Agent): void {
|
||||
@@ -621,10 +621,10 @@ async function pump(chatId: string): Promise<void> {
|
||||
// Connection changes are the only wake for consent-deferred automatic work.
|
||||
// No retry timer, inference, or API-key fallback is started while consent is off.
|
||||
state().stopConnectionWatch?.();
|
||||
state().stopConnectionWatch = onChatGPTChange(() => {
|
||||
state().stopConnectionWatch = onSubscriptionChange(() => {
|
||||
const s = state();
|
||||
for (const [chatId, agent] of s.liveAgents) {
|
||||
if (agent.state?.model?.provider !== "chatgpt" || automaticFollowUpAllowed(chatId, agent)) continue;
|
||||
if (!isSubscriptionProvider(agent.state?.model?.provider) || automaticFollowUpAllowed(chatId, agent)) continue;
|
||||
const consumed = new Set(agent.state.messages.map((m) => (m as { id?: string }).id));
|
||||
const pending = (s.queues.get(chatId) ?? []).filter((entry) => entry.handedTo === agent && !consumed.has(entry.steer.id));
|
||||
if (pending.length === 0) continue;
|
||||
|
||||
+2
-2
@@ -52,7 +52,7 @@ import {
|
||||
validateKeyShape,
|
||||
} from "../core/auth-keys.ts";
|
||||
import { authStore } from "../store/auth.ts";
|
||||
import { chatGPTConnected } from "../agent/chatgpt-bridge.ts";
|
||||
import { isSubscriptionProvider, subscriptionConnected } from "../agent/subscription-connections.ts";
|
||||
|
||||
// The once-per-process boot record — globalThis anchor (HMR re-imports the
|
||||
// module; the snapshot must not re-run and misread fill-gapped vars as real
|
||||
@@ -201,7 +201,7 @@ export function keyValue(id: string): string | undefined {
|
||||
* (gate by what would run). Providers absent from AUTH_PROVIDER_KEYS take
|
||||
* no key at all: nothing to gate, present by definition. */
|
||||
export function providerKeyPresent(provider: string): boolean {
|
||||
if (provider === "chatgpt") return chatGPTConnected();
|
||||
if (isSubscriptionProvider(provider)) return subscriptionConnected(provider);
|
||||
const info = AUTH_PROVIDER_KEYS.find((p) => p.id === provider);
|
||||
return info ? keyValue(info.envVar) !== undefined : true;
|
||||
}
|
||||
|
||||
@@ -21,14 +21,19 @@ import { clampThinkingLevel, getSupportedThinkingLevels, type Api, type Model }
|
||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import { piModels } from "../agent/models.ts";
|
||||
import { chatGPTConnected, chatGPTModel, chatGPTModels, refreshChatGPTModels } from "../agent/chatgpt-bridge.ts";
|
||||
import { NATIVE_SUBSCRIPTIONS } from "../core/contracts/subscriptions.ts";
|
||||
import { nativeSubscriptionId, nativeSubscriptionConnected, nativeSubscriptionModel, nativeSubscriptionModels, refreshSubscriptionModels } from "../agent/subscription-bridge.ts";
|
||||
|
||||
export async function refreshConnectionCatalog(): Promise<void> {
|
||||
await refreshChatGPTModels(piModels().getModels("openai").filter((m): m is Model<"openai-responses"> => m.api === "openai-responses"));
|
||||
export async function refreshConnectionCatalog(provider?: string): Promise<void> {
|
||||
await Promise.all([
|
||||
!provider || provider === "chatgpt" ? refreshChatGPTModels(piModels().getModels("openai").filter((m): m is Model<"openai-responses"> => m.api === "openai-responses")) : Promise.resolve(),
|
||||
!provider || nativeSubscriptionId(provider) ? refreshSubscriptionModels() : Promise.resolve(),
|
||||
]);
|
||||
}
|
||||
function connectionModel(provider: string, id: string): Model<Api> | undefined {
|
||||
return provider === "chatgpt" ? chatGPTModel(id) : piModels().getModel(provider, id);
|
||||
return provider === "chatgpt" ? chatGPTModel(id) : nativeSubscriptionId(provider) ? nativeSubscriptionModel(provider, id) : piModels().getModel(provider, id);
|
||||
}
|
||||
function connectionProviderIds(): string[] { return [...piModels().getProviders().map((p) => p.id), "chatgpt"]; }
|
||||
function connectionProviderIds(): string[] { return [...piModels().getProviders().map((p) => p.id), "chatgpt", ...NATIVE_SUBSCRIPTIONS.map((p) => p.logicalId)]; }
|
||||
import { settingsStore } from "../store/settings.ts";
|
||||
import { effectiveSettings } from "./stores.ts";
|
||||
import { keyValue } from "./keys.ts";
|
||||
@@ -134,7 +139,10 @@ export function catalogProviders(): CatalogProvider[] {
|
||||
id: p.id,
|
||||
envVar: p.envVar,
|
||||
keyPresent: keyValue(p.envVar) !== undefined,
|
||||
})).concat([{ id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() }]).sort((a, b) => a.id.localeCompare(b.id));
|
||||
})).concat([
|
||||
{ id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() },
|
||||
...NATIVE_SUBSCRIPTIONS.map((p) => ({ id: p.logicalId, envVar: "", keyPresent: nativeSubscriptionConnected(p.providerId) })),
|
||||
]).sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
/** The providers whose key is present — the picker federates exactly
|
||||
@@ -180,7 +188,7 @@ export function catalogContextLimits(): Map<string, number> {
|
||||
index.set(`${provider.id}:${model.id}`, model.contextWindow);
|
||||
}
|
||||
}
|
||||
for (const model of chatGPTModels()) index.set(`chatgpt:${model.id}`, model.contextWindow);
|
||||
for (const model of [...chatGPTModels(), ...nativeSubscriptionModels()]) index.set(`${model.provider}:${model.id}`, model.contextWindow);
|
||||
return index;
|
||||
}
|
||||
|
||||
@@ -196,7 +204,7 @@ export function modelCatalog(): ModelCatalogResponse {
|
||||
.flatMap((p) => piModels().getModels(p.id))
|
||||
.map(toModelDto)
|
||||
.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)))
|
||||
.concat(chatGPTModels().map(toModelDto));
|
||||
.concat([...chatGPTModels(), ...nativeSubscriptionModels()].map(toModelDto));
|
||||
return {
|
||||
models,
|
||||
defaults: catalogDefaults(),
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
import { calculateCost, type Usage } from "@earendil-works/pi-ai";
|
||||
import { piModels } from "../agent/models.ts";
|
||||
import type { CallUsage, ChatRecord, JobRecord, TurnUsage } from "../core/contracts.ts";
|
||||
import { isChatGPTPlan, sessionCost as foldSessionCost, turnCost as foldTurnCost, jobCost as foldJobCost } from "../core/prices.ts";
|
||||
import { isSubscriptionModel, sessionCost as foldSessionCost, turnCost as foldTurnCost, jobCost as foldJobCost } from "../core/prices.ts";
|
||||
|
||||
function catalogModel(id: string) {
|
||||
const colon = id.indexOf(":");
|
||||
if (colon < 0 || isChatGPTPlan(id)) return undefined;
|
||||
if (colon < 0 || isSubscriptionModel(id)) return undefined;
|
||||
return piModels().getModel(id.slice(0, colon), id.slice(colon + 1));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user