Files
PaperClipAI/packages/shared/src/ai-connections.ts
T
377bc87df1 feat(connectors): show and select personal AI account defaults (#19845)
Show a crown on each saved personal AI provider default. Add a provider-specific menu action to other eligible accounts. Share saved-state queries, block duplicate writes, and keep the saved crown until a write succeeds.

Keep account details available for accessible grants. Show session lookup errors with Retry. Resolve list permissions once per request and index account lookups. Add UI and API regression coverage and account documentation.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <Paperclip-Paperclip@users.noreply.github.com>
2026-11-07 10:26:08 -06:00

290 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { z } from "zod";
import { aiProviderRoutingSchema, aiRoutingHarness, isAiRoutingCompatible, type AiProviderRouting } from "./ai-provider-routing.js";
/** Runtime authentication is a separate transport, never a tool or channel. */
export const connectionPurposeTransportSchema = z.discriminatedUnion(
"connectionPurpose",
[
z.object({
connectionPurpose: z.literal("tool"),
transport: z.enum(["mcp_remote", "rest_api", "local_stdio"]),
}),
z.object({
connectionPurpose: z.literal("channel"),
transport: z.enum(["chat_sdk", "rest_api", "voice"]),
config: z.object({ provider: z.string().optional() }).passthrough().optional(),
}).refine(
(connection) => connection.transport === "voice" ? connection.config?.provider === "speko" : connection.transport === "chat_sdk" ? connection.config?.provider !== "speko" : connection.config?.provider === "agentmail",
{ message: "Channel transport must match its provider: voice for Speko, REST for AgentMail, Chat SDK otherwise", path: ["config", "provider"] },
),
z.object({
connectionPurpose: z.literal("ai"),
transport: z.literal("runtime_auth"),
}),
],
);
export type ConnectionPurposeTransport = z.infer<
typeof connectionPurposeTransportSchema
>;
export const AI_PROVIDERS = [
"anthropic",
"openai",
"openrouter",
"xai",
"github",
"google",
] as const;
export const aiProviderSchema = z.enum(AI_PROVIDERS);
export const aiAuthMethodSchema = z.enum(["subscription", "api_key"]);
export type AiProvider = z.infer<typeof aiProviderSchema>;
export type AiAuthMethod = z.infer<typeof aiAuthMethodSchema>;
const requirement = { provider: aiProviderSchema, method: aiAuthMethodSchema };
export const aiConnectionBindingSchema = z.discriminatedUnion("mode", [
z.object({
provider: aiProviderSchema,
// Retained on the wire for older servers during rolling upgrades. The
// responsible user's provider default determines the actual run method.
method: aiAuthMethodSchema,
mode: z.literal("responsible_user"),
}).strict(),
z
.object({
...requirement,
mode: z.literal("shared"),
connectionId: z.string().uuid(),
grantId: z.string().uuid(),
})
.strict(),
z
.object({
...requirement,
// Explicit personal selection. Human access still applies on every run.
mode: z.literal("delegated"),
connectionId: z.string().uuid(),
grantId: z.string().uuid(),
})
.strict(),
]);
export type AiConnectionBinding = z.infer<typeof aiConnectionBindingSchema>;
export const aiConnectionMetadataSchema = z.object({ ...requirement, routing: aiProviderRoutingSchema.optional() }).strict();
export type AiConnectionMetadata = z.infer<typeof aiConnectionMetadataSchema>;
/** Existing integrations only. This table describes compatibility, never routing. */
export const AI_CONNECTION_CAPABILITIES: Record<
AiProvider,
{
name: string;
methods: Partial<
Record<AiAuthMethod, { adapters: readonly string[]; envKey: string }>
>;
}
> = {
github: {
name: "GitHub Copilot",
methods: { api_key: { adapters: ["copilot_runtime"], envKey: "COPILOT_GITHUB_TOKEN" } },
},
google: { name: "Google", methods: {
api_key: { adapters: ["gemini_local", "pi_local", "antigravity_runtime"], envKey: "GEMINI_API_KEY" },
subscription: { adapters: ["antigravity_runtime"], envKey: "GEMINI_HOME" },
} },
anthropic: {
name: "Claude",
methods: {
subscription: {
adapters: ["claude_local"],
envKey: "CLAUDE_CODE_OAUTH_TOKEN",
},
api_key: { adapters: ["claude_local", "pi_local"], envKey: "ANTHROPIC_API_KEY" },
},
},
openai: {
name: "OpenAI",
methods: {
subscription: { adapters: ["codex_local"], envKey: "CODEX_HOME" },
api_key: { adapters: ["codex_local", "pi_local"], envKey: "OPENAI_API_KEY" },
},
},
openrouter: {
name: "OpenRouter",
methods: {
api_key: { adapters: ["opencode_local", "pi_local"], envKey: "OPENROUTER_API_KEY" },
},
},
xai: {
name: "Grok",
methods: {
subscription: { adapters: ["grok_local"], envKey: "GROK_HOME" },
api_key: { adapters: ["grok_local"], envKey: "XAI_API_KEY" },
},
},
};
export function isAiConnectionCompatible(
requirement: AiConnectionMetadata | AiConnectionBinding,
adapterType: string,
model?: unknown,
runnerProvider?: unknown,
acpxAgent?: unknown,
): boolean {
adapterType = aiRoutingHarness(adapterType, runnerProvider, acpxAgent);
// Antigravity uses its own Google runtime sign-in or a Gemini API key.
// Tool OAuth and API-compatible endpoints cannot authenticate this harness.
if (adapterType === "antigravity_runtime") return requirement.provider === "google"
&& (requirement.method === "api_key" || requirement.method === "subscription")
&& !("routing" in requirement && requirement.routing);
if ("routing" in requirement && requirement.routing) return requirement.method === "api_key" && isAiRoutingCompatible(requirement.routing, adapterType);
// A fixed binding contains identity only. The service checks authoritative
// connection metadata before resolving credentials or running the harness.
if (adapterType === "pi_local") return (requirement.method === "api_key" || ("mode" in requirement && requirement.mode === "responsible_user"))
&& ["openrouter", "openai", "anthropic", "google"].includes(requirement.provider)
&& typeof model === "string" && model.startsWith(`${requirement.provider}/`);
if ("mode" in requirement && requirement.mode !== "responsible_user" && requirement.method === "api_key")
return ["claude_local", "codex_local", "opencode_local", "hermes_local", "gemini_local", "grok_local", "copilot_runtime"].includes(adapterType);
const methods = AI_CONNECTION_CAPABILITIES[requirement.provider].methods;
const candidates = "mode" in requirement && requirement.mode === "responsible_user"
? Object.values(methods)
: requirement.method ? [methods[requirement.method]] : [];
return (
candidates.some((method) => method?.adapters.includes(adapterType)) &&
(requirement.provider !== "openrouter" ||
(typeof model === "string" && model.startsWith("openrouter/")))
);
}
export type AiConnectionUnavailableReason =
| "responsible_user_missing"
| "membership_missing"
| "default_missing"
| "connection_missing"
| "connection_unavailable"
| "incompatible"
| "access_denied"
| "credential_missing";
export interface AiConnectionAttribution {
/** Billing account at execution time; never resolved again from mutable settings. */
subscriptionId?: string;
connectionId: string;
grantId: string;
provider: AiProvider;
method: AiAuthMethod;
mode: AiConnectionBinding["mode"];
responsibleUserId: string | null;
}
export type AiConnectionResolution =
| { ok: true; attribution: AiConnectionAttribution }
| { ok: false; reason: AiConnectionUnavailableReason; message: string };
export interface AiManagedConnectionSummary {
id: string;
grantId: string;
companyId: string;
provider: AiProvider;
method: AiAuthMethod;
name: string;
routing?: AiProviderRouting;
accountLabel?: string;
ownership: "personal" | "shared";
ownerUserId?: string;
ownerName?: string;
isDefault: boolean;
canSetDefault?: boolean;
status: "connected" | "needs_attention" | "expired" | "revoked";
unavailableReason?: string;
usageProbeSupported?: boolean;
}
/** Safe provider account details for one authorized saved credential. */
export interface AiConnectionAccountIdentity {
connectionId: string;
grantId: string;
status: "available" | "unavailable" | "error";
checkedAt: string;
accountLabel: string | null;
organizationLabel: string | null;
errorCode?: "unsupported" | "connection_unavailable" | "authentication_required" | "permission_denied" | "rate_limited" | "provider_unavailable" | "invalid_response";
message?: string;
}
export interface AiConnectionList {
currentUserId: string;
pools?: import("./ai-connection-router.js").AiConnectionPool[];
canManageConnections: boolean;
connections: AiManagedConnectionSummary[];
}
export const createAiConnectionSchema = z
.object({
...requirement,
name: z.string().trim().min(1).max(160),
ownership: z.enum(["personal", "shared"]),
routing: aiProviderRoutingSchema.optional(),
apiKey: z.string().trim().min(1).max(32768).optional(),
environmentId: z.string().uuid().nullable().optional(),
loginSessionId: z.string().max(128).optional(),
connectionId: z.string().uuid().optional(),
agentIds: z.array(z.string().uuid()).max(1000).default([]),
allAgents: z.boolean().default(false),
})
.strict()
.superRefine((v, ctx) => {
if (!AI_CONNECTION_CAPABILITIES[v.provider].methods[v.method])
ctx.addIssue({ code: "custom", message: "Unsupported sign-in method" });
if (v.routing && (v.method !== "api_key" || (v.routing.kind === "openrouter" && v.provider !== "openrouter") || (v.routing.kind === "bedrock" && v.provider !== "anthropic")))
ctx.addIssue({ code: "custom", message: "Routing requires the matching provider and API authentication." });
if (v.routing && ["gateway", "local"].includes(v.routing.kind) && v.provider !== (v.routing.protocol === "messages" ? "anthropic" : "openai"))
ctx.addIssue({ code: "custom", message: "The provider must match the endpoint’s API format." });
if (v.routing?.auth === "none") {
if (v.apiKey || v.loginSessionId) ctx.addIssue({ code: "custom", message: "Provide only the selected authentication method." });
return;
}
if (
v.method === "api_key"
? !v.apiKey || Boolean(v.loginSessionId)
: !v.loginSessionId || Boolean(v.apiKey)
) {
ctx.addIssue({
code: "custom",
message:
"Provide exactly the credential for the selected sign-in method",
});
}
});
export type CreateAiConnection = z.infer<typeof createAiConnectionSchema>;
export const aiConnectionLoginIntentSchema = z
.object({
provider: z.enum(["anthropic", "openai", "xai", "google"]),
method: z.literal("subscription"),
name: z.string().trim().min(1).max(160),
ownership: z.enum(["personal", "shared"]),
connectionId: z.string().uuid().optional(),
agentIds: z.array(z.string().uuid()).max(1000).default([]),
allAgents: z.boolean().default(false),
})
.strict();
export type AiConnectionLoginIntent = z.infer<
typeof aiConnectionLoginIntentSchema
>;
export const localAiConnectionSchema = aiConnectionLoginIntentSchema.extend({
localSessionId: z.string().uuid().optional(),
});
export const localAiLoginStartSchema = aiConnectionLoginIntentSchema.extend({ restart: z.boolean().optional() });
export interface LocalAiLoginStatus {
status: "ready" | "sign_in_required" | "expired";
authorizationUrl?: string;
code?: string;
error?: string;
accountLabel?: string;
}
export interface LocalAiLoginAttempt {
sessionId: string;
/** Grok still uses terminal sign-in until it has an in-app login runner. */
command?: string;
expiresAt: string;
}
/** Preview-era copies of rotating local credentials must be reconnected. */
export function aiSubscriptionNeedsIsolatedLogin(config: Record<string, unknown> | undefined): boolean {
const metadata = aiConnectionMetadataSchema.safeParse(config?.ai);
return metadata.success && metadata.data.method === "subscription" &&
(["openai", "xai", "google"].includes(metadata.data.provider)) &&
config?.aiIsolatedSubscription !== true;
}